Add [sandbox.env] support to workflow configs

Allow passing environment variables into sandbox command execution via
`[sandbox.env]` in TOML configs. Supports literal values and host env
passthrough via `${env.VARNAME}` syntax (whole-value only, missing vars
are hard errors). Env vars are injected into command nodes via
`cmd.envs()` and into CLI backend agents via the sandbox env file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-07 22:43:16 -05:00
parent 36d35d07fe
commit 5d6691df22
17 changed files with 324 additions and 10 deletions

View file

@ -932,6 +932,7 @@ mod runs {
network: Some(arc_workflows::daytona_sandbox::DaytonaNetwork::Block),
}),
exe: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("repo_url".into(), "https://github.com/org/api-server".into()),
@ -1052,6 +1053,7 @@ mod workflows {
network: None,
}),
exe: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("repo_url".into(), "https://github.com/org/service".into()),
@ -1117,6 +1119,7 @@ mod workflows {
network: None,
}),
exe: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("spec_path".into(), "specs/feature.md".into()),
@ -1193,6 +1196,7 @@ mod workflows {
network: None,
}),
exe: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("source_env".into(), "production".into()),
@ -1260,6 +1264,7 @@ mod workflows {
network: None,
}),
exe: None,
env: None,
}),
vars: Some(std::collections::HashMap::from([
("analytics_window".into(), "30d".into()),
@ -2653,6 +2658,7 @@ mod settings {
network: Some(arc_workflows::daytona_sandbox::DaytonaNetwork::Block),
}),
exe: None,
env: None,
}),
vars: None,
checkpoint: Default::default(),

View file

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
@ -330,16 +331,32 @@ pub fn parse_cli_response(provider: Provider, output: &str) -> Option<CliRespons
}
}
/// Escape a value for safe embedding inside single quotes in a shell command.
fn shell_escape(val: &str) -> String {
val.replace('\'', "'\\''")
}
/// CLI backend that invokes external CLI tools (claude, codex, gemini) via `exec_command()`.
pub struct AgentCliBackend {
model: String,
provider: Provider,
env: HashMap<String, String>,
}
impl AgentCliBackend {
#[must_use]
pub fn new(model: String, provider: Provider) -> Self {
Self { model, provider }
Self {
model,
provider,
env: HashMap::new(),
}
}
#[must_use]
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env = env;
self
}
/// Detect changed files by comparing git state before and after the CLI run.
@ -455,8 +472,11 @@ impl CodergenBackend for AgentCliBackend {
env_lines.extend(provider.api_key_env_vars().iter().filter_map(|name| {
std::env::var(name)
.ok()
.map(|val| format!("export {name}='{val}'"))
.map(|val| format!("export {name}='{}'", shell_escape(&val)))
}));
for (name, val) in &self.env {
env_lines.push(format!("export {name}='{}'", shell_escape(val)));
}
{
sandbox
.write_file(&env_path, &env_lines.join("\n"))

View file

@ -659,13 +659,23 @@ pub async fn run_command(
);
// 7. Build engine
let registry = default_registry(interviewer.clone(), || {
if dry_run_mode {
None
} else {
let api = AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone());
let cli = AgentCliBackend::new(model.clone(), provider_enum);
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
let sandbox_env: HashMap<String, String> = run_cfg
.as_ref()
.and_then(|c| c.sandbox.as_ref())
.and_then(|s| s.env.clone())
.unwrap_or_default();
let registry = default_registry(interviewer.clone(), {
let sandbox_env = sandbox_env.clone();
let model = model.clone();
move || {
if dry_run_mode {
None
} else {
let api = AgentApiBackend::new(model.clone(), provider_enum, fallback_chain.clone());
let cli = AgentCliBackend::new(model.clone(), provider_enum)
.with_env(sandbox_env.clone());
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
}
}
});
let mut engine = WorkflowRunEngine::with_interviewer(
@ -674,6 +684,9 @@ pub async fn run_command(
interviewer,
Arc::clone(&sandbox),
);
if !sandbox_env.is_empty() {
engine.set_env(sandbox_env);
}
// Wire up hook runner from run config
if let Some(ref cfg) = run_cfg {
@ -1740,6 +1753,7 @@ mod tests {
preserve: Some(false),
daytona: None,
exe: None,
env: None,
}),
vars: None,
hooks: Vec::new(),
@ -1763,6 +1777,7 @@ mod tests {
preserve: Some(true),
daytona: None,
exe: None,
env: None,
}),
vars: None,
hooks: Vec::new(),
@ -1774,6 +1789,7 @@ mod tests {
preserve: Some(false),
daytona: None,
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -1788,6 +1804,7 @@ mod tests {
preserve: Some(true),
daytona: None,
exe: None,
env: None,
}),
..RunDefaults::default()
};

View file

@ -53,6 +53,7 @@ pub struct SandboxConfig {
pub preserve: Option<bool>,
pub daytona: Option<DaytonaConfig>,
pub exe: Option<arc_exe::ExeConfig>,
pub env: Option<HashMap<String, String>>,
}
/// Defaults for workflow runs, loaded from the server config.
@ -135,6 +136,13 @@ impl WorkflowRunConfig {
(None, Some(_)) => task.daytona = default.daytona.clone(),
_ => {}
}
if let Some(ref default_env) = default.env {
let mut merged = default_env.clone();
if let Some(ref task_env) = task.env {
merged.extend(task_env.clone());
}
task.env = Some(merged);
}
}
(None, Some(_)) => self.sandbox = defaults.sandbox.clone(),
_ => {}
@ -171,10 +179,47 @@ pub fn load_run_config(path: &Path) -> anyhow::Result<WorkflowRunConfig> {
let config_dir = path.parent().unwrap_or(Path::new("."));
resolve_dockerfile(&mut config, config_dir)?;
resolve_sandbox_env(&mut config)?;
Ok(config)
}
/// Resolve `${env.VARNAME}` references in `[sandbox.env]` values.
///
/// Only whole-value references are supported (no partial interpolation).
/// Missing host env vars produce a hard error.
fn resolve_sandbox_env(config: &mut WorkflowRunConfig) -> anyhow::Result<()> {
if let Some(env) = config
.sandbox
.as_mut()
.and_then(|s| s.env.as_mut())
{
resolve_env_refs(env)?;
}
Ok(())
}
/// Resolve `${env.VARNAME}` patterns in a map of env vars.
///
/// If the entire value is `${env.VARNAME}`, it is replaced with the host
/// environment variable. Any other value is left as-is. Missing host
/// variables produce an error.
pub fn resolve_env_refs(env: &mut HashMap<String, String>) -> anyhow::Result<()> {
for (key, value) in env.iter_mut() {
if let Some(var_name) = value
.strip_prefix("${env.")
.and_then(|s| s.strip_suffix('}'))
{
*value = std::env::var(var_name).with_context(|| {
format!(
"sandbox.env.{key}: host environment variable {var_name:?} is not set"
)
})?;
}
}
Ok(())
}
/// If the config contains a `dockerfile = { path = "..." }`, read the file
/// and replace it with `DockerfileSource::Inline(contents)`.
fn resolve_dockerfile(config: &mut WorkflowRunConfig, config_dir: &Path) -> anyhow::Result<()> {
@ -882,6 +927,7 @@ preserve = true
preserve: Some(false),
daytona: None,
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -908,6 +954,7 @@ provider = "docker"
preserve: Some(true),
daytona: None,
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -937,6 +984,7 @@ provider = "daytona"
..DaytonaConfig::default()
}),
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -973,6 +1021,7 @@ auto_stop_interval = 60
..DaytonaConfig::default()
}),
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -1008,6 +1057,7 @@ env = "from_task"
..DaytonaConfig::default()
}),
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -1047,6 +1097,7 @@ cpu = 2
..DaytonaConfig::default()
}),
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -1085,6 +1136,7 @@ auto_stop_interval = 60
..DaytonaConfig::default()
}),
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -1318,6 +1370,7 @@ network = "block"
..DaytonaConfig::default()
}),
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -1352,6 +1405,7 @@ auto_stop_interval = 60
..DaytonaConfig::default()
}),
exe: None,
env: None,
}),
..RunDefaults::default()
};
@ -1477,4 +1531,189 @@ exclude_globs = ["**/node_modules/**"]
vec!["**/node_modules/**"]
);
}
#[test]
fn parse_toml_with_sandbox_env() {
let toml = r#"
version = 1
goal = "test"
graph = "w.dot"
[sandbox.env]
FOO = "bar"
BAZ = "${env.HOME}"
"#;
let config = parse_run_config(toml).unwrap();
let env = config.sandbox.unwrap().env.unwrap();
assert_eq!(env["FOO"], "bar");
// Not yet resolved (parse_run_config doesn't resolve env refs)
assert_eq!(env["BAZ"], "${env.HOME}");
}
#[test]
fn parse_toml_without_sandbox_env() {
let toml = r#"
version = 1
goal = "test"
graph = "w.dot"
[sandbox]
provider = "daytona"
"#;
let config = parse_run_config(toml).unwrap();
assert!(config.sandbox.unwrap().env.is_none());
}
#[test]
fn resolve_env_refs_literal_passthrough() {
let mut env = HashMap::from([("FOO".into(), "bar".into())]);
resolve_env_refs(&mut env).unwrap();
assert_eq!(env["FOO"], "bar");
}
#[test]
fn resolve_env_refs_host_var() {
std::env::set_var("ARC_TEST_RESOLVE_VAR", "secret123");
let mut env = HashMap::from([
("MY_KEY".into(), "${env.ARC_TEST_RESOLVE_VAR}".into()),
]);
resolve_env_refs(&mut env).unwrap();
assert_eq!(env["MY_KEY"], "secret123");
std::env::remove_var("ARC_TEST_RESOLVE_VAR");
}
#[test]
fn resolve_env_refs_missing_var_errors() {
let mut env = HashMap::from([
("MY_KEY".into(), "${env.ARC_TEST_NONEXISTENT_VAR_12345}".into()),
]);
let err = resolve_env_refs(&mut env).unwrap_err();
assert!(
err.to_string().contains("ARC_TEST_NONEXISTENT_VAR_12345"),
"unexpected error: {err}"
);
}
#[test]
fn resolve_env_refs_partial_not_interpolated() {
let mut env = HashMap::from([
("MIXED".into(), "prefix_${env.HOME}_suffix".into()),
]);
// Partial interpolation is not supported — value is left as-is
resolve_env_refs(&mut env).unwrap();
assert_eq!(env["MIXED"], "prefix_${env.HOME}_suffix");
}
#[test]
fn apply_defaults_merges_sandbox_env() {
let mut cfg = parse_run_config(
r#"
version = 1
goal = "test"
graph = "w.dot"
[sandbox.env]
TASK_KEY = "task_val"
SHARED = "from_task"
"#,
)
.unwrap();
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: None,
preserve: None,
daytona: None,
exe: None,
env: Some(HashMap::from([
("DEFAULT_KEY".into(), "default_val".into()),
("SHARED".into(), "from_default".into()),
])),
}),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
let env = cfg.sandbox.unwrap().env.unwrap();
assert_eq!(env["DEFAULT_KEY"], "default_val");
assert_eq!(env["TASK_KEY"], "task_val");
assert_eq!(env["SHARED"], "from_task");
}
#[test]
fn apply_defaults_sandbox_env_from_default_only() {
let mut cfg = parse_run_config(
r#"
version = 1
goal = "test"
graph = "w.dot"
[sandbox]
provider = "daytona"
"#,
)
.unwrap();
let defaults = RunDefaults {
sandbox: Some(SandboxConfig {
provider: None,
preserve: None,
daytona: None,
exe: None,
env: Some(HashMap::from([("KEY".into(), "val".into())])),
}),
..RunDefaults::default()
};
cfg.apply_defaults(&defaults);
let env = cfg.sandbox.unwrap().env.unwrap();
assert_eq!(env["KEY"], "val");
}
#[test]
fn load_run_config_resolves_env_refs() {
std::env::set_var("ARC_TEST_LOAD_ENV", "resolved_value");
let dir = tempfile::tempdir().unwrap();
let toml_path = dir.path().join("run.toml");
std::fs::write(
&toml_path,
r#"
version = 1
goal = "test"
graph = "w.dot"
[sandbox.env]
LITERAL = "hello"
FROM_HOST = "${env.ARC_TEST_LOAD_ENV}"
"#,
)
.unwrap();
let config = load_run_config(&toml_path).unwrap();
let env = config.sandbox.unwrap().env.unwrap();
assert_eq!(env["LITERAL"], "hello");
assert_eq!(env["FROM_HOST"], "resolved_value");
std::env::remove_var("ARC_TEST_LOAD_ENV");
}
#[test]
fn load_run_config_missing_env_var_errors() {
let dir = tempfile::tempdir().unwrap();
let toml_path = dir.path().join("run.toml");
std::fs::write(
&toml_path,
r#"
version = 1
goal = "test"
graph = "w.dot"
[sandbox.env]
MISSING = "${env.ARC_TEST_DEFINITELY_NOT_SET_67890}"
"#,
)
.unwrap();
let err = load_run_config(&toml_path).unwrap_err();
assert!(
err.to_string()
.contains("ARC_TEST_DEFINITELY_NOT_SET_67890"),
"unexpected error: {err}"
);
}
}

View file

@ -860,6 +860,7 @@ impl WorkflowRunEngine {
sandbox,
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
},
interviewer: None,
}
@ -875,6 +876,7 @@ impl WorkflowRunEngine {
sandbox: Arc::clone(&services.sandbox),
git_state: std::sync::RwLock::new(None),
hook_runner: services.hook_runner.clone(),
env: services.env.clone(),
},
interviewer: None,
}
@ -895,6 +897,7 @@ impl WorkflowRunEngine {
sandbox,
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
},
interviewer: Some(interviewer),
}
@ -905,6 +908,11 @@ impl WorkflowRunEngine {
self.services.hook_runner = Some(runner);
}
/// Set environment variables from `[sandbox.env]` config.
pub fn set_env(&mut self, env: HashMap<String, String>) {
self.services.env = env;
}
/// Run lifecycle hooks and return the merged decision.
/// Returns `Proceed` if no hook runner is configured.
async fn run_hooks(

View file

@ -322,6 +322,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
}
}
@ -458,6 +459,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
};
let outcome = handler
@ -519,6 +521,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
};
let outcome = handler

View file

@ -25,7 +25,7 @@ impl Handler for CommandHandler {
context: &Context,
_graph: &Graph,
logs_root: &Path,
_services: &EngineServices,
services: &EngineServices,
) -> Result<Outcome, ArcError> {
let script = node
.attrs
@ -69,11 +69,13 @@ impl Handler for CommandHandler {
tokio::process::Command::new("python3")
.arg("-c")
.arg(script)
.envs(&services.env)
.output()
} else {
tokio::process::Command::new("sh")
.arg("-c")
.arg(script)
.envs(&services.env)
.output()
};
@ -183,6 +185,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}

View file

@ -45,6 +45,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}

View file

@ -42,6 +42,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}

View file

@ -307,6 +307,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}

View file

@ -281,6 +281,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}

View file

@ -264,6 +264,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
}
}
@ -396,6 +397,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
};
let handler = SubWorkflowHandler;
@ -504,6 +506,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
};
let handler = SubWorkflowHandler;
@ -564,6 +567,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
};
let handler = SubWorkflowHandler;
@ -736,6 +740,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
};
let handler = SubWorkflowHandler;
@ -820,6 +825,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: HashMap::new(),
};
let handler = SubWorkflowHandler;

View file

@ -36,6 +36,8 @@ pub struct EngineServices {
pub(crate) git_state: std::sync::RwLock<Option<Arc<GitState>>>,
/// Hook runner for user-defined lifecycle hooks.
pub hook_runner: Option<Arc<HookRunner>>,
/// Environment variables from `[sandbox.env]` config, injected into command nodes.
pub env: HashMap<String, String>,
}
impl EngineServices {

View file

@ -394,6 +394,7 @@ impl Handler for ParallelHandler {
let registry = Arc::clone(&services.registry);
let emitter = Arc::clone(&services.emitter);
let hook_runner = services.hook_runner.clone();
let env = services.env.clone();
let graph = graph.clone();
let logs_root = logs_root.to_path_buf();
let sem = Arc::clone(&semaphore);
@ -438,6 +439,7 @@ impl Handler for ParallelHandler {
sandbox: Arc::clone(&setup.sandbox),
git_state: std::sync::RwLock::new(None),
hook_runner: hook_runner.clone(),
env: env.clone(),
};
let handler = registry.resolve(target_node);
let outcome = handler
@ -771,6 +773,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}

View file

@ -134,6 +134,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}

View file

@ -41,6 +41,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}

View file

@ -56,6 +56,7 @@ mod tests {
)),
git_state: std::sync::RwLock::new(None),
hook_runner: None,
env: std::collections::HashMap::new(),
}
}