fabro/lib/components/fabro-agent/tests/it/docker_shell.rs
Bryan Helmkamp 91d905813f
Map an environment straight onto the driver's sandbox spec
SandboxOptions was an intermediate between the environment's settings and
the driver's SandboxSpec that mirrored the spec field for field: image
and Dockerfile for the source, cpu and byte sizes for the resources,
auto-stop for the timers, plus the two clone fields. Every provider
overlay then read the options a second time to fill the spec.

The environment now maps onto the driver spec once, in
sandbox_spec_for_environment, and the overlays read the spec: Docker
takes its image from the source and clears the timers it cannot honor,
Daytona takes its snapshot inputs from the source and resources and its
auto-stop from the timers. A plugin gets the spec trimmed to the network
and timer capabilities it declares. The snapshot carries the resources a
Daytona sandbox is sized by, so the overlay clears them from the spec
the sandbox is created with; the driver refuses them there, which the
options path never reached in a live run.

The clone selectors, depth, and skip flag travel as one CloneRequest
beside the spec instead of five loose parameters and two option fields,
so provider_sandbox takes six arguments instead of nine. The two helpers
that read environment settings for a local run, its working directory
and its unresolved variables, become methods on RunEnvironmentSettings
in fabro-types, where the settings live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:44:21 -06:00

97 lines
3.4 KiB
Rust

//! Proves the agent shell tool reports real process outcomes through the
//! Docker provider's streaming path, which uses a `bash -lc` supervisor and
//! separate stdout/stderr channels.
use std::sync::Arc;
use fabro_agent::event::SessionBoundEmitter;
use fabro_agent::tool_registry::ToolContext;
use fabro_agent::tools::make_shell_tool;
use fabro_agent::types::AgentEvent;
use fabro_agent::{
CloneRequest, DriverSpec, Emitter, ProviderAccess, SandboxProviderKind, SandboxSource,
provider_sandbox,
};
use fabro_types::CommandTermination;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
#[tokio::test]
#[ignore = "requires real Docker container lifecycle; run explicitly when changing shell tool exec integration"]
async fn shell_reports_real_docker_process_outcome() {
let Ok(sandbox) = provider_sandbox(
SandboxProviderKind::DOCKER,
&ProviderAccess::default(),
DriverSpec::new(SandboxSource::Image {
reference: "buildpack-deps:noble".to_string(),
}),
&CloneRequest::none(),
None,
None,
)
.await
else {
return;
};
// No Docker daemon or no local image: the integration precondition is not met.
if sandbox.initialize().await.is_err() {
return;
}
let sandbox = Arc::new(sandbox);
let emitter = Emitter::new();
let mut receiver = emitter.subscribe();
let tool = make_shell_tool();
let result = (tool.executor)(
serde_json::json!({"command": "printf 'out'; printf 'err' >&2; exit 7"}),
ToolContext {
env: sandbox.clone(),
cancel: CancellationToken::new(),
tool_env_provider: None,
session_id: Some("test-session".to_string()),
root_session_id: Some("test-session".to_string()),
tool_call_id: Some("call_1".to_string()),
agent_event_emitter: Some(Arc::new(SessionBoundEmitter::new(
emitter.clone(),
"test-session".to_string(),
Some("call_1".to_string()),
))),
},
)
.await;
sandbox
.cleanup()
.await
.expect("docker cleanup should succeed");
let output = result.expect_err("exit 7 is a failed tool result");
assert!(output.contains("Termination: exited"), "got: {output}");
assert!(output.contains("Exit code: 7"), "got: {output}");
assert!(output.contains("stdout:\nout"), "got: {output}");
assert!(output.contains("stderr:\nerr"), "got: {output}");
let event = receiver.try_recv().expect("one process event");
assert_eq!(event.session_id, "test-session");
assert_eq!(event.tool_call_id.as_deref(), Some("call_1"));
assert!(matches!(
receiver.try_recv(),
Err(broadcast::error::TryRecvError::Empty)
));
match event.event {
AgentEvent::ToolProcessCompleted {
exit_code,
termination,
streams_separated,
exec_output_tail,
..
} => {
assert_eq!(exit_code, Some(7));
assert_eq!(termination, CommandTermination::Exited);
assert!(streams_separated);
let tail = exec_output_tail.expect("output tail");
assert_eq!(tail.stdout.as_deref(), Some("out"));
assert_eq!(tail.stderr.as_deref(), Some("err"));
}
other => panic!("expected a process event, got {other:?}"),
}
}