P6: shell.exec — agents execute code in their real hardened sandbox
- SandboxManager (tc-runtime): one container per agent, provisioned
lazily on first use, reused for the manager's lifetime, replaced
transparently if dead, destroyed on shutdown
- shell.exec tool: sh -lc inside the agent's sandbox; stdout/stderr/
exit_code return to the model as the step output. No external effects
declared — the sandbox boundary (uid 10001, no caps, seccomp
allowlist, read-only rootfs, zero egress) is the §15 control here,
not an approval gate
- RuntimeConfig.sandboxes (+ with_sandboxes builder); [sandbox] config
{image, enabled}; the server connects the Docker driver at boot and
tolerates an absent engine (shell.exec reports it per-call)
- Tests with the REAL DockerDriver: a scripted run executes two
commands — output proves uid 10001 from inside, and /home/agent state
written by the first call is read by the second (same sandbox); a
deployment without a sandbox runtime records honest error steps and
the run still completes
151 Rust tests + 27 Playwright journeys.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
84c51168be
commit
7392ce1d08
Generated
+2
@@ -3453,6 +3453,7 @@ dependencies = [
|
|||||||
"tc-files",
|
"tc-files",
|
||||||
"tc-llm",
|
"tc-llm",
|
||||||
"tc-safety",
|
"tc-safety",
|
||||||
|
"tc-sandbox",
|
||||||
"tc-secrets",
|
"tc-secrets",
|
||||||
"tc-testkit",
|
"tc-testkit",
|
||||||
"tc-tools",
|
"tc-tools",
|
||||||
@@ -3594,6 +3595,7 @@ dependencies = [
|
|||||||
"tc-files",
|
"tc-files",
|
||||||
"tc-llm",
|
"tc-llm",
|
||||||
"tc-runtime",
|
"tc-runtime",
|
||||||
|
"tc-sandbox",
|
||||||
"tc-scheduler",
|
"tc-scheduler",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ tc-db = { path = "../../tc-db" }
|
|||||||
tc-files = { path = "../../tc-files" }
|
tc-files = { path = "../../tc-files" }
|
||||||
tc-llm = { path = "../../tc-llm" }
|
tc-llm = { path = "../../tc-llm" }
|
||||||
tc-runtime = { path = "../../tc-runtime" }
|
tc-runtime = { path = "../../tc-runtime" }
|
||||||
|
tc-sandbox = { path = "../../tc-sandbox" }
|
||||||
tc-scheduler = { path = "../../tc-scheduler" }
|
tc-scheduler = { path = "../../tc-scheduler" }
|
||||||
tc-domain = { path = "../../tc-domain" }
|
tc-domain = { path = "../../tc-domain" }
|
||||||
time = { workspace = true }
|
time = { workspace = true }
|
||||||
|
|||||||
@@ -83,6 +83,22 @@ async fn run() -> Result<(), String> {
|
|||||||
.map_err(|e| format!("s3 storage: {e}"))?,
|
.map_err(|e| format!("s3 storage: {e}"))?,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
// Environment tools need a container engine; absence is tolerated
|
||||||
|
// (shell.exec reports it per-call) so the API still serves.
|
||||||
|
let sandboxes = if config.sandbox.enabled {
|
||||||
|
match tc_sandbox::DockerDriver::connect() {
|
||||||
|
Ok(driver) => Some(std::sync::Arc::new(tc_runtime::SandboxManager::new(
|
||||||
|
std::sync::Arc::new(driver),
|
||||||
|
&config.sandbox.image,
|
||||||
|
))),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("teamclaw-server: sandbox engine unavailable: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let runtime = Runtime::with_blob_store(
|
let runtime = Runtime::with_blob_store(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
provider,
|
provider,
|
||||||
@@ -91,6 +107,7 @@ async fn run() -> Result<(), String> {
|
|||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
broker_socket: Some(PathBuf::from(&config.broker.socket_path)),
|
broker_socket: Some(PathBuf::from(&config.broker.socket_path)),
|
||||||
slack_base_url: config.slack.base_url.clone(),
|
slack_base_url: config.slack.base_url.clone(),
|
||||||
|
sandboxes,
|
||||||
},
|
},
|
||||||
blob,
|
blob,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
|
|||||||
RuntimeConfig {
|
RuntimeConfig {
|
||||||
model: "scripted".into(),
|
model: "scripted".into(),
|
||||||
max_tokens: 1024,
|
max_tokens: 1024,
|
||||||
|
sandboxes: None,
|
||||||
broker_socket: Some(socket.clone()),
|
broker_socket: Some(socket.clone()),
|
||||||
slack_base_url: "http://127.0.0.1:1".into(), // never reached here
|
slack_base_url: "http://127.0.0.1:1".into(), // never reached here
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -132,6 +132,23 @@ impl Default for SlackConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct SandboxConfig {
|
||||||
|
/// Agent sandbox image (must exist locally / be preloaded in cluster).
|
||||||
|
pub image: String,
|
||||||
|
/// Disable to run without environment tools (shell.exec errors).
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SandboxConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
SandboxConfig {
|
||||||
|
image: "teamclaw/agent-base:dev".into(),
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
pub struct OAuthConfig {
|
pub struct OAuthConfig {
|
||||||
/// Default identity provider for directory-app OAuth connects.
|
/// Default identity provider for directory-app OAuth connects.
|
||||||
@@ -157,6 +174,8 @@ pub struct AppConfig {
|
|||||||
pub slack: SlackConfig,
|
pub slack: SlackConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub oauth: OAuthConfig,
|
pub oauth: OAuthConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sandbox: SandboxConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ tc-domain = { path = "../tc-domain" }
|
|||||||
tc-files = { path = "../tc-files" }
|
tc-files = { path = "../tc-files" }
|
||||||
tc-llm = { path = "../tc-llm" }
|
tc-llm = { path = "../tc-llm" }
|
||||||
tc-safety = { path = "../tc-safety" }
|
tc-safety = { path = "../tc-safety" }
|
||||||
|
tc-sandbox = { path = "../tc-sandbox" }
|
||||||
tc-secrets = { path = "../tc-secrets" }
|
tc-secrets = { path = "../tc-secrets" }
|
||||||
tc-tools = { path = "../tc-tools" }
|
tc-tools = { path = "../tc-tools" }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
mod events;
|
mod events;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
mod sandboxes;
|
||||||
pub mod scheduling;
|
pub mod scheduling;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
pub use events::{RunEventBody, RunEventEnvelope};
|
pub use events::{RunEventBody, RunEventEnvelope};
|
||||||
pub use runtime::{Runtime, RuntimeConfig, RuntimeError, StartedRun};
|
pub use runtime::{Runtime, RuntimeConfig, RuntimeError, StartedRun};
|
||||||
|
pub use sandboxes::SandboxManager;
|
||||||
pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry};
|
pub use tools::{ClockNow, EmailSend, Tool, ToolContext, ToolRegistry};
|
||||||
|
|||||||
@@ -34,9 +34,19 @@ pub struct RuntimeConfig {
|
|||||||
pub broker_socket: Option<std::path::PathBuf>,
|
pub broker_socket: Option<std::path::PathBuf>,
|
||||||
/// Slack API base (e2e points it at a local sink).
|
/// Slack API base (e2e points it at a local sink).
|
||||||
pub slack_base_url: String,
|
pub slack_base_url: String,
|
||||||
|
/// Sandbox runtime for shell.exec; None disables environment tools.
|
||||||
|
pub sandboxes: Option<std::sync::Arc<crate::SandboxManager>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeConfig {
|
impl RuntimeConfig {
|
||||||
|
pub fn with_sandboxes(
|
||||||
|
mut self,
|
||||||
|
sandboxes: std::sync::Arc<crate::SandboxManager>,
|
||||||
|
) -> RuntimeConfig {
|
||||||
|
self.sandboxes = Some(sandboxes);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Test/dev defaults: no broker, real Slack base.
|
/// Test/dev defaults: no broker, real Slack base.
|
||||||
pub fn basic(model: &str, max_tokens: u32) -> RuntimeConfig {
|
pub fn basic(model: &str, max_tokens: u32) -> RuntimeConfig {
|
||||||
RuntimeConfig {
|
RuntimeConfig {
|
||||||
@@ -44,6 +54,7 @@ impl RuntimeConfig {
|
|||||||
max_tokens,
|
max_tokens,
|
||||||
broker_socket: None,
|
broker_socket: None,
|
||||||
slack_base_url: "https://slack.com/api".into(),
|
slack_base_url: "https://slack.com/api".into(),
|
||||||
|
sandboxes: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,6 +380,7 @@ impl Runtime {
|
|||||||
approval_id: Some(ready.approval_id),
|
approval_id: Some(ready.approval_id),
|
||||||
broker_socket: self.inner.config.broker_socket.clone(),
|
broker_socket: self.inner.config.broker_socket.clone(),
|
||||||
slack_base_url: self.inner.config.slack_base_url.clone(),
|
slack_base_url: self.inner.config.slack_base_url.clone(),
|
||||||
|
sandboxes: self.inner.config.sandboxes.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
state.step_seq += 1;
|
state.step_seq += 1;
|
||||||
@@ -439,6 +451,7 @@ impl Runtime {
|
|||||||
approval_id: None,
|
approval_id: None,
|
||||||
broker_socket: self.inner.config.broker_socket.clone(),
|
broker_socket: self.inner.config.broker_socket.clone(),
|
||||||
slack_base_url: self.inner.config.slack_base_url.clone(),
|
slack_base_url: self.inner.config.slack_base_url.clone(),
|
||||||
|
sandboxes: self.inner.config.sandboxes.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! Per-agent sandbox lifecycle for environment tools. One hardened
|
||||||
|
//! container per agent, provisioned lazily on first use and reused for
|
||||||
|
//! the manager's lifetime; a dead sandbox is replaced transparently.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tc_domain::AgentId;
|
||||||
|
use tc_sandbox::{ExecResult, SandboxDriver, SandboxHandle, SandboxSpec};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SandboxManager {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("SandboxManager")
|
||||||
|
.field("image", &self.image)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SandboxManager {
|
||||||
|
driver: Arc<dyn SandboxDriver>,
|
||||||
|
image: String,
|
||||||
|
handles: Mutex<HashMap<AgentId, SandboxHandle>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxManager {
|
||||||
|
pub fn new(driver: Arc<dyn SandboxDriver>, image: &str) -> SandboxManager {
|
||||||
|
SandboxManager {
|
||||||
|
driver,
|
||||||
|
image: image.to_owned(),
|
||||||
|
handles: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `sh -lc <command>` in the agent's sandbox, provisioning it on
|
||||||
|
/// first use. The sandbox has no egress and no credentials — running
|
||||||
|
/// agent-authored code here is the point of the architecture.
|
||||||
|
pub async fn exec(&self, agent_id: AgentId, command: &str) -> Result<ExecResult, String> {
|
||||||
|
let mut handles = self.handles.lock().await;
|
||||||
|
let alive = match handles.get(&agent_id) {
|
||||||
|
Some(handle) => self.driver.health(handle).await.unwrap_or(false),
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
if !alive {
|
||||||
|
let short = uuid::Uuid::now_v7().simple().to_string();
|
||||||
|
let spec = SandboxSpec {
|
||||||
|
name: format!("tc-agent-{}", &short[short.len() - 12..]),
|
||||||
|
image: self.image.clone(),
|
||||||
|
memory_bytes: 512 * 1024 * 1024,
|
||||||
|
nano_cpus: 1_000_000_000,
|
||||||
|
pids_limit: 256,
|
||||||
|
};
|
||||||
|
let handle = self
|
||||||
|
.driver
|
||||||
|
.provision(&spec)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("sandbox provision failed: {e}"))?;
|
||||||
|
handles.insert(agent_id, handle);
|
||||||
|
}
|
||||||
|
let handle = handles.get(&agent_id).expect("just ensured");
|
||||||
|
self.driver
|
||||||
|
.exec(handle, &["sh", "-lc", command])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("sandbox exec failed: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroys every sandbox this manager provisioned.
|
||||||
|
pub async fn shutdown(&self) {
|
||||||
|
let mut handles = self.handles.lock().await;
|
||||||
|
for (_, handle) in handles.drain() {
|
||||||
|
let _ = self.driver.destroy(&handle).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ mod clock;
|
|||||||
mod email;
|
mod email;
|
||||||
mod files;
|
mod files;
|
||||||
mod routine;
|
mod routine;
|
||||||
|
mod shell;
|
||||||
mod slack;
|
mod slack;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -39,6 +40,7 @@ pub struct ToolContext {
|
|||||||
pub approval_id: Option<uuid::Uuid>,
|
pub approval_id: Option<uuid::Uuid>,
|
||||||
pub broker_socket: Option<std::path::PathBuf>,
|
pub broker_socket: Option<std::path::PathBuf>,
|
||||||
pub slack_base_url: String,
|
pub slack_base_url: String,
|
||||||
|
pub sandboxes: Option<Arc<crate::SandboxManager>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -80,6 +82,7 @@ impl Default for ToolRegistry {
|
|||||||
registry.register(Arc::new(RoutineSchedule));
|
registry.register(Arc::new(RoutineSchedule));
|
||||||
registry.register(Arc::new(ChatSend));
|
registry.register(Arc::new(ChatSend));
|
||||||
registry.register(Arc::new(ChatInbox));
|
registry.register(Arc::new(ChatInbox));
|
||||||
|
registry.register(Arc::new(shell::ShellExec));
|
||||||
registry.register(Arc::new(SlackPost));
|
registry.register(Arc::new(SlackPost));
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! shell.exec — agent-authored commands in the agent's own hardened
|
||||||
|
//! sandbox (uid 10001, no caps, seccomp allowlist, read-only rootfs,
|
||||||
|
//! ZERO egress). No external reach means no approval gate: the sandbox
|
||||||
|
//! boundary, not a human, is the §15 control for this tool.
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use tc_tools::Effect;
|
||||||
|
|
||||||
|
use super::{Tool, ToolContext, ToolDescriptor};
|
||||||
|
|
||||||
|
pub struct ShellExec;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for ShellExec {
|
||||||
|
fn descriptor(&self) -> ToolDescriptor {
|
||||||
|
ToolDescriptor {
|
||||||
|
name: "shell.exec".into(),
|
||||||
|
description: "Run a shell command inside your sandboxed computer. \
|
||||||
|
No network access; /home/agent persists between calls."
|
||||||
|
.into(),
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": { "type": "string", "description": "Command for sh -lc" }
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effects(&self) -> &'static [Effect] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
ctx: &ToolContext,
|
||||||
|
input: serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let command = input["command"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("shell.exec requires a 'command' string")?;
|
||||||
|
let sandboxes = ctx
|
||||||
|
.sandboxes
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("no sandbox runtime is configured on this deployment")?;
|
||||||
|
let result = sandboxes.exec(ctx.agent_id, command).await?;
|
||||||
|
Ok(json!({
|
||||||
|
"exit_code": result.exit_code,
|
||||||
|
"stdout": result.stdout,
|
||||||
|
"stderr": result.stderr,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! Agents execute code in their REAL hardened sandbox: shell.exec runs in
|
||||||
|
//! a per-agent container (Docker driver), output returns to the model as a
|
||||||
|
//! step result, and /home/agent state persists across calls in a run.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tc_domain::{
|
||||||
|
AccessPolicy, Agent, AgentId, AgentStatus, Role, RunState, User, UserId, Workspace, WorkspaceId,
|
||||||
|
};
|
||||||
|
use tc_llm::ScriptedProvider;
|
||||||
|
use tc_runtime::{Runtime, RuntimeConfig, SandboxManager};
|
||||||
|
use tc_sandbox::DockerDriver;
|
||||||
|
|
||||||
|
const IMAGE: &str = "teamclaw/agent-base:dev";
|
||||||
|
|
||||||
|
const SCENARIOS: &str = r#"
|
||||||
|
[[scenario]]
|
||||||
|
marker = "[[scenario:shell]]"
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "tool_use", name = "shell.exec", input = { command = "id -u && echo tick > /home/agent/state" } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "tool_use", name = "shell.exec", input = { command = "cat /home/agent/state" } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = "Ran both commands." },
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn ensure_image() {
|
||||||
|
let exists = Command::new("docker")
|
||||||
|
.args(["image", "inspect", IMAGE])
|
||||||
|
.output()
|
||||||
|
.expect("docker available")
|
||||||
|
.status
|
||||||
|
.success();
|
||||||
|
if !exists {
|
||||||
|
let root = env!("CARGO_MANIFEST_DIR");
|
||||||
|
let status = Command::new("docker")
|
||||||
|
.args([
|
||||||
|
"build",
|
||||||
|
"-t",
|
||||||
|
IMAGE,
|
||||||
|
"-f",
|
||||||
|
&format!("{root}/../../images/agent-base/Dockerfile"),
|
||||||
|
&format!("{root}/../../images/agent-base"),
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.expect("docker build runs");
|
||||||
|
assert!(status.success(), "agent-base image build failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shell_exec_runs_in_the_agent_sandbox_with_persistent_home() {
|
||||||
|
ensure_image();
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
|
||||||
|
let ws = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let owner = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
email: format!("{}@acme.test", UserId::new()),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
|
||||||
|
let agent = Agent {
|
||||||
|
id: AgentId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
name: "Scout".into(),
|
||||||
|
job_title: "Analyst".into(),
|
||||||
|
system_prompt: String::new(),
|
||||||
|
avatar: String::new(),
|
||||||
|
accent: String::new(),
|
||||||
|
wallpaper: String::new(),
|
||||||
|
managed_by: owner.id,
|
||||||
|
status: AgentStatus::Online,
|
||||||
|
};
|
||||||
|
tc_db::repo::agents::insert(&pool, &agent, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let driver = DockerDriver::connect().expect("docker reachable");
|
||||||
|
let sandboxes = Arc::new(SandboxManager::new(Arc::new(driver), IMAGE));
|
||||||
|
let rt = Runtime::new(
|
||||||
|
pool.clone(),
|
||||||
|
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
|
||||||
|
RuntimeConfig::basic("scripted", 1024).with_sandboxes(sandboxes.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let session = tc_db::repo::sessions::create(&pool, agent.id, ws.id, "Shell")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let started = rt
|
||||||
|
.send_message(session.id, "run it [[scenario:shell]]")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut rx = started.events;
|
||||||
|
while let Ok(envelope) = rx.recv().await {
|
||||||
|
if matches!(
|
||||||
|
envelope.event,
|
||||||
|
tc_runtime::RunEventBody::RunCompleted { .. } | tc_runtime::RunEventBody::Error { .. }
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let run = tc_db::repo::runs::get(&pool, started.run_id).await.unwrap();
|
||||||
|
assert_eq!(run.state, RunState::Completed);
|
||||||
|
|
||||||
|
// Step outputs prove kernel-level facts: uid 10001 inside, and the
|
||||||
|
// second call saw the first call's file — same sandbox, same run.
|
||||||
|
let outputs: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||||
|
"SELECT s.output FROM steps s
|
||||||
|
JOIN messages m ON m.id = s.message_id
|
||||||
|
WHERE m.session_id = $1 AND s.tool_name = 'shell.exec'
|
||||||
|
ORDER BY s.seq",
|
||||||
|
)
|
||||||
|
.bind(session.id.as_uuid())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outputs.len(), 2);
|
||||||
|
assert_eq!(outputs[0]["exit_code"], 0, "first: {}", outputs[0]);
|
||||||
|
assert!(
|
||||||
|
outputs[0]["stdout"].as_str().unwrap().contains("10001"),
|
||||||
|
"sandbox runs as uid 10001: {}",
|
||||||
|
outputs[0]
|
||||||
|
);
|
||||||
|
assert_eq!(outputs[1]["exit_code"], 0, "second: {}", outputs[1]);
|
||||||
|
assert!(
|
||||||
|
outputs[1]["stdout"].as_str().unwrap().contains("tick"),
|
||||||
|
"home persists across execs: {}",
|
||||||
|
outputs[1]
|
||||||
|
);
|
||||||
|
|
||||||
|
sandboxes.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shell_exec_without_a_sandbox_runtime_reports_a_tool_error() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let owner = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
email: format!("{}@acme.test", UserId::new()),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
|
||||||
|
let agent = Agent {
|
||||||
|
id: AgentId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
name: "Scout".into(),
|
||||||
|
job_title: "Analyst".into(),
|
||||||
|
system_prompt: String::new(),
|
||||||
|
avatar: String::new(),
|
||||||
|
accent: String::new(),
|
||||||
|
wallpaper: String::new(),
|
||||||
|
managed_by: owner.id,
|
||||||
|
status: AgentStatus::Online,
|
||||||
|
};
|
||||||
|
tc_db::repo::agents::insert(&pool, &agent, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(
|
||||||
|
pool.clone(),
|
||||||
|
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
|
||||||
|
RuntimeConfig::basic("scripted", 1024),
|
||||||
|
);
|
||||||
|
let session = tc_db::repo::sessions::create(&pool, agent.id, ws.id, "NoBox")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let started = rt
|
||||||
|
.send_message(session.id, "run it [[scenario:shell]]")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut rx = started.events;
|
||||||
|
while let Ok(envelope) = rx.recv().await {
|
||||||
|
if matches!(
|
||||||
|
envelope.event,
|
||||||
|
tc_runtime::RunEventBody::RunCompleted { .. } | tc_runtime::RunEventBody::Error { .. }
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The run survives: the tool reports its error to the model, which
|
||||||
|
// finishes the scenario.
|
||||||
|
let run = tc_db::repo::runs::get(&pool, started.run_id).await.unwrap();
|
||||||
|
assert_eq!(run.state, RunState::Completed);
|
||||||
|
let statuses: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT s.status FROM steps s
|
||||||
|
JOIN messages m ON m.id = s.message_id
|
||||||
|
WHERE m.session_id = $1 AND s.tool_name = 'shell.exec'
|
||||||
|
ORDER BY s.seq",
|
||||||
|
)
|
||||||
|
.bind(session.id.as_uuid())
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!statuses.is_empty());
|
||||||
|
assert!(
|
||||||
|
statuses.iter().all(|s| s == "error"),
|
||||||
|
"steps must record the failure: {statuses:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -140,6 +140,7 @@ async fn slack_post_blocks_then_the_broker_executes_exactly_once() {
|
|||||||
RuntimeConfig {
|
RuntimeConfig {
|
||||||
model: "scripted".into(),
|
model: "scripted".into(),
|
||||||
max_tokens: 1024,
|
max_tokens: 1024,
|
||||||
|
sandboxes: None,
|
||||||
broker_socket: Some(socket),
|
broker_socket: Some(socket),
|
||||||
slack_base_url: sink_url,
|
slack_base_url: sink_url,
|
||||||
},
|
},
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
Reference in New Issue
Block a user