mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* feat(engine-v2): mount-backend abstraction for per-project sandbox (Phase 1) Adds the engine-side `MountBackend` trait + minimal `WorkspaceMounts` registry and a host-side bridge interceptor that routes sandbox-eligible tool calls (`file_read`, `file_write`, `list_dir`, `apply_patch`, `shell`) through a backend when their path argument starts with `/project/`. Default behavior is unchanged: until `EffectBridgeAdapter::set_workspace_mounts(Some(...))` is called (Phase 6), the interception path is dormant. This is the first phase of the per-project sandbox plan (`docs/plans/2026-04-10-engine-v2-sandbox.md`) and a deliberately small subset of the unified Workspace VFS proposed in nearai/ironclaw#1894 — just enough abstraction so the sandbox can be a `MountBackend` rather than a special case in the bridge. When #1894's full mount table lands, the sandbox backend slots in unchanged. Engine crate (`crates/ironclaw_engine/src/workspace/`): - `mount.rs` — `MountBackend` trait, `MountError` (NotFound / InvalidPath / PermissionDenied / Io / Tool / Backend / Unsupported), `DirEntry`, `EntryKind`, `ShellOutput` - `filesystem.rs` — `FilesystemBackend`: passthrough host-fs implementation with two-layer path validation (lexical reject of absolute / `..`, then symlink-escape canonicalization). `read`/`write`/`list` fully implemented; `patch`/`shell` return `Unsupported` so the bridge falls through to the host tool until Phase 5 - `registry.rs` — `WorkspaceMounts` per-project registry with lazy `ProjectMountFactory`, longest-prefix-match resolution, cached and invalidatable Bridge (`src/bridge/sandbox/`): - `intercept.rs` — `maybe_intercept` and `SANDBOX_TOOL_NAMES`. Returns `Handled(json)` on a successful backend dispatch, `FellThrough` for non-sandbox tools, host paths, missing path params, or `Unsupported` backend ops - `effect_adapter.rs` — `workspace_mounts` field + `set_workspace_mounts` setter; interception block in `execute_action_internal` right before `execute_tool_with_safety`, gated on the optional mount table Tests (31 new): - 17 engine workspace unit tests covering trait error mapping, path safety (lexical + symlink), longest-prefix routing, and lazy factory caching - 9 bridge sandbox unit tests including `intercept_actually_dispatches_into_backend` (counting backend) which proves the interceptor reaches the backend - 5 integration tests in `tests/engine_v2_sandbox_integration.rs` driving `EffectBridgeAdapter::execute_action()` end-to-end per the "Test Through the Caller" rule (`.claude/rules/testing.md`), including a host-path-falls-through test that asserts the sandbox tempdir was not touched, and a `..`-escape test that verifies no `/etc/passwd` content leaks even after safety-layer redaction Drive-by: feature-gate two pre-existing dead-code helpers in `crates/ironclaw_skills/src/parser.rs` on `#[cfg(feature = "registry")]` to match their only call site, fixing a pre-existing clippy warning that blocked the workspace's `-D warnings` policy when `ironclaw_skills` is built with `default-features = false` (as the engine crate does). Verification: - `cargo fmt --check` clean - `cargo clippy --all --benches --tests --examples --all-features` zero warnings - 31 / 31 new tests passing; no existing tests broken Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(engine-v2): per-project sandbox — Phases 2–7 + live Docker e2e test Completes the per-project sandbox plan (docs/plans/2026-04-10-engine-v2-sandbox.md Phases 2–7), building on Phase 1's mount-backend abstraction (#2211). Phase 2 — Project workspace folder: - `Project.workspace_path: Option<PathBuf>` field + `with_workspace_path()` - Host-side `project_workspace_path()`, `ensure_project_workspace_dir()` (creates `~/.ironclaw/projects/<id>/` mode 0700, idempotent) - `FilesystemMountFactory` taking a `ProjectPathResolver` closure (decoupled from `Store`); wired into `EffectBridgeAdapter` via `set_workspace_mounts()` Phase 3 — Standalone daemon binary: - `src/bin/sandbox_daemon.rs` — NDJSON over stdin/stdout, health/shutdown/execute_tool - Constructs ReadFileTool/WriteFileTool/ListDirTool/ApplyPatchTool/ShellTool with `base_dir=/project` (override via `IRONCLAW_SANDBOX_BASE_DIR`) Phase 4 — Dockerfile.sandbox: - Multi-stage build: rust-slim builder (+ python3 for pyo3) compiles sandbox_daemon; debian-slim runtime with tini PID 1, common build tools, `/project` mount target Phase 5 — ProjectSandboxManager + ContainerizedFilesystemBackend: - protocol.rs: Request/Response/RpcError matching daemon wire format - transport.rs: `SandboxTransport` trait (seam for testing without Docker) - containerized_backend.rs: `ContainerizedFilesystemBackend` impls `MountBackend`, translates relative→`/project/<rel>`, maps tool-error→MountError - docker_transport.rs: real bollard exec session, serialized Mutex, lazy reconnect - lifecycle.rs: deterministic `ironclaw-sandbox-<pid>` naming, ensure_running/stop/remove - manager.rs: `ProjectSandboxManager` per-project transport cache Phase 6 — Router gating on ENGINE_V2_SANDBOX: - `engine_v2_sandbox_enabled()` helper (truthy: 1/true/yes/on) - Router selects `ContainerizedMountFactory` when enabled + Docker reachable; falls back to `FilesystemMountFactory` with warning otherwise Live e2e bugs caught and fixed: - Shell without explicit `workdir` defaulted to host (not sandbox); fixed by defaulting to `/project/` in `extract_path_param` - `ContainerizedFilesystemBackend::shell` parsed `stdout`/`stderr` but host ShellTool returns merged `output` field; fixed with fallback key lookup - SANDBOX_TOOL_NAMES only had v2 names (`file_read`/`file_write`) but host registry uses v1 names (`read_file`/`write_file`); added both aliases Tests (62 sandbox-related, all green): - 27 bridge sandbox unit tests (intercept, workspace_path, factory, protocol, lifecycle, containerized_backend with ScriptedTransport mock) - 7 containerized-backend tests (including 2 regression tests for the shell bugs) - 5 engine v2 sandbox integration tests (EffectBridgeAdapter end-to-end) - 5 daemon binary smoke tests (real subprocess + NDJSON I/O) - 17 engine workspace unit tests - 1 live Docker e2e test: agent clones nearai/ironclaw into sandbox, renames to megaclaw via sed, verifies with grep — 70s, $0.09, recorded trace committed Verification: - `cargo fmt --check` clean - `cargo clippy --all --benches --tests --examples --all-features` zero warnings - All 62 sandbox tests passing; no existing tests broken Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace .expect() with Result in DockerTransport::ensure_session CI's no-panics checker flagged the .expect("just inserted") in production code. Replace with .ok_or_else() returning MountError::Backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: multi-tenant project paths + unify sandbox env var with v1 Two issues addressed: 1. Project workspace paths now namespace by user_id: `~/.ironclaw/projects/<user_id>/<project_id>/` instead of `~/.ironclaw/projects/<project_id>/`. Prevents filesystem collisions in multi-tenant deployments where two users could theoretically have the same project UUID. 2. Sandbox enablement now reads `SANDBOX_ENABLED` (same env var as v1 sandbox) in addition to `ENGINE_V2_SANDBOX`. Either being truthy enables the per-project sandbox. This means a single flag governs sandbox behavior regardless of engine version, while the v2-specific override remains available for transitional setups. Tests: 30 bridge sandbox unit tests passing (added multi-tenant path tests + env var combination tests). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — TOCTOU race, shell env passthrough, canonicalize guard Three issues flagged by the code review bot on #2211: 1. TOCTOU race in WorkspaceMounts::resolve (HIGH): Added double-checked locking — re-check the cache after acquiring the write lock so two threads racing on the same project's first access don't both call factory.build(). The second thread finds the insert from the first. 2. Shell intercept ignores env parameter (MEDIUM): The shell arm in maybe_intercept was passing HashMap::new() instead of forwarding the tool call's env map. Fixed to parse parameters["env"] and pass it through to backend.shell(). 3. Canonicalization fails when root doesn't exist (MEDIUM): When self.root hasn't been created yet (first write to a new project), canonicalize_under_root would walk up to a real ancestor and the starts_with check against the non-existent root would always fail. Now skips canonicalization entirely when root doesn't exist — lexical safety is already guaranteed by safe_join. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review round 2 — apply_patch schema, content validation, dir perms, docs - Fix apply_patch schema mismatch: MountBackend::patch now takes (old_string, new_string, replace_all) matching ApplyPatchTool's actual contract. Previously sent {patch: diff} which would fail with invalid_params in the containerized daemon. - Validate file_write content param: return error instead of silently writing empty string when content is missing. - Log stderr frames from sandbox daemon at debug! instead of silently discarding them in docker_transport StreamReader. - Tighten permissions on intermediate directories created by ensure_project_workspace_dir (projects/, <user_id>/) to 0o700, not just the leaf. - Fix stale module doc in sandbox/mod.rs (referenced "Phase 5 will add" but all phases shipped). - Fix doc path mismatch: workspace path is <user_id>/<project_id>/, not <project_id>/ (workspace_path.rs, CLAUDE.md, design plan). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review round 3 — symlink safety, visibility, debug logging - Close TOCTOU window in canonicalize_under_root: re-canonicalize and verify containment when the reassembled path exists on disk - Fix list_dir_recursive: use symlink_metadata (lstat) so symlinks are detected instead of followed; validate directories against root before recursive traversal - Tighten is_mountable_path to /project/, /memory/, /home/ prefixes instead of any absolute path (defense-in-depth) - Narrow sandbox module visibility to pub(crate) and remove unused pub use re-exports - Remove concrete types (FilesystemBackend, DirEntry, EntryKind, ShellOutput) from engine crate top-level re-exports; access via ironclaw_engine::workspace:: module path - Add debug! tracing to sandbox intercept routing decisions - Add read_file/write_file v1 aliases to daemon SUPPORTED_TOOLS health response - Remove developer-local path from sandbox mod.rs doc comment - Merge staging to fix CI (user_timezone field on ThreadExecutionContext) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review round 4 — safety validation, network isolation, binary writes - Add pre-intercept safety param validation so sandbox-dispatched calls go through the same checks as host-dispatched calls (#1) - Set network_mode: "none" on sandbox containers to prevent outbound network access (#3) - Reject binary content in containerized write instead of silently corrupting via from_utf8_lossy (#5) - Cap list_dir depth to 10 to prevent unbounded traversal (#8) - Change container creation log from info! to debug! to avoid breaking REPL/TUI output (#10) - Make is_truthy case-insensitive so SANDBOX_ENABLED=True works (#11) - Return error instead of unwrap_or_default for missing container ID (#12) - Propagate set_permissions errors instead of silently ignoring (#13) - Return error for missing daemon output key instead of defaulting to empty object (#14) - Add env mutex guard in sandbox_live_e2e test (#15) - Fix rustfmt formatting for let-chain in canonicalize_under_root Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review round 5 — path traversal, error types, tests Security fixes: - Sanitize user_id in workspace path to prevent directory traversal via malicious user IDs containing `..` or `/` - Add Component::ParentDir check in ContainerizedFilesystemBackend::container_path matching the defense-in-depth approach of FilesystemBackend::safe_join Correctness: - Use MountError::Tool instead of MountError::InvalidPath for missing tool parameters (content, old_string, new_string) — fixes confusing LLM-visible error messages - Fix clippy sort_by_key suggestion in registry.rs Cleanup: - Remove spurious Notify import and dead _notify_link function New tests: - ContainerizedFilesystemBackend path traversal rejection (read + write) - container_path unit tests for safe and unsafe paths - Adversarial user_id test in workspace_path - Daemon-side path traversal test in sandbox_daemon_smoke Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review round 6 — param normalization, error types, edge cases - Normalize sandbox params via prepare_tool_params() before validation, matching the host execution path (fixes inconsistent validation) - Return ToolError::InvalidParameters instead of EngineError::Effect for sandbox param validation failures (consistent error surface) - ensure_dir checks path.is_dir() not path.exists() (rejects files) - Empty user_id returns "_anonymous" sentinel instead of empty hex string that would drop the tenant namespace via PathBuf::join("") - Restore ENGINE_V2_SANDBOX env var after sandbox live E2E test - Tighten is_mountable_path to /project/ only (no mounts for /memory/ or /home/ yet) - Add v1 tool name aliases (read_file, write_file) to SUPPORTED_TOOLS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: unify sandbox env var — remove ENGINE_V2_SANDBOX, use SANDBOX_ENABLED only Single env var controls sandboxing for both engine versions. The transitional ENGINE_V2_SANDBOX override is removed from code, tests, docs, and Dockerfile. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: double-checked locking in transport_for, explicit stdin close in smoke test - ProjectSandboxManager::transport_for no longer holds the mutex across the Docker ensure_running await. Uses double-checked locking so concurrent projects initialize in parallel. - sandbox_daemon_smoke: explicitly take() stdin before wait_with_output so EOF is sent even without a shutdown request. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review — network mode, error types, race, protocol dedup - Change sandbox container network_mode from "none" to default bridge so git clone / cargo build / pip install work inside the container - Fix binary content rejection to use MountError::Tool instead of MountError::InvalidPath (semantic mismatch) - Fix list depth: use actual depth value instead of depth.max(1) - Fix orphan container race in transport_for by holding lock across container creation instead of double-checked locking - Deduplicate protocol types: daemon now imports from shared bridge::sandbox::protocol instead of defining its own copies - Make bridge::sandbox pub (narrow exposure: only protocol and workspace_path sub-modules are pub) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update plan doc — sandbox uses bridge networking, not network_mode=none Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
11 KiB
Rust
243 lines
11 KiB
Rust
//! Live end-to-end test for the engine v2 per-project sandbox.
|
|
//!
|
|
//! This test proves that the entire sandbox path — mount table routing,
|
|
//! `ContainerizedFilesystemBackend`, `ProjectSandboxManager`, Docker exec
|
|
//! session, and the in-container `sandbox_daemon` — works under a real
|
|
//! agent driving a real LLM. It is the manual-verification replacement:
|
|
//! "clone ironclaw, rename to megaclaw, run cargo check" as a single
|
|
//! asserted scenario.
|
|
//!
|
|
//! # Running
|
|
//!
|
|
//! **Live mode (real LLM + real Docker):**
|
|
//! ```bash
|
|
//! # 1) build the sandbox image once
|
|
//! docker build -f crates/Dockerfile.sandbox -t ironclaw/sandbox:dev .
|
|
//!
|
|
//! # 2) run the test
|
|
//! SANDBOX_ENABLED=true IRONCLAW_LIVE_TEST=1 \
|
|
//! cargo test --features libsql --test sandbox_live_e2e -- --ignored --nocapture
|
|
//! ```
|
|
//!
|
|
//! **Replay mode:**
|
|
//! ```bash
|
|
//! SANDBOX_ENABLED=true \
|
|
//! cargo test --features libsql --test sandbox_live_e2e -- --ignored --nocapture
|
|
//! ```
|
|
//!
|
|
//! Both modes require Docker + the sandbox image — the actual filesystem
|
|
//! side effects happen inside a real container either way. The difference
|
|
//! is whether the LLM calls are recorded (live) or replayed from a
|
|
//! committed trace fixture (replay). If Docker or the image is unavailable
|
|
//! the test skips with a helpful message rather than failing.
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod support;
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod sandbox_e2e_tests {
|
|
use std::time::Duration;
|
|
|
|
use crate::support::live_harness::LiveTestHarnessBuilder;
|
|
|
|
/// Prints a skip reason and returns from the test.
|
|
macro_rules! skip {
|
|
($($arg:tt)*) => {
|
|
eprintln!("[SandboxE2E] SKIP: {}", format!($($arg)*));
|
|
return;
|
|
};
|
|
}
|
|
|
|
async fn docker_reachable() -> bool {
|
|
ironclaw::sandbox::connect_docker().await.is_ok()
|
|
}
|
|
|
|
async fn sandbox_image_present(image: &str) -> bool {
|
|
match ironclaw::sandbox::connect_docker().await {
|
|
Ok(docker) => docker.inspect_image(image).await.is_ok(),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// The judge criteria: the agent must report that it cloned the repo,
|
|
/// performed the rename, and verified it with grep. Used in live mode
|
|
/// only — replay mode has no judge provider.
|
|
const JUDGE_CRITERIA: &str = "\
|
|
The assistant reports that it (1) successfully cloned a repository \
|
|
into /project/repo, (2) renamed occurrences of 'ironclaw' to \
|
|
'megaclaw' in at least one file (typically Cargo.toml), and (3) \
|
|
verified the rename by grepping for 'megaclaw' and finding it. \
|
|
All three steps must be mentioned with concrete evidence \
|
|
(command output, line number, or file path).";
|
|
|
|
/// Live/replay end-to-end: the agent is asked to clone ironclaw into the
|
|
/// sandbox, rename it to megaclaw, and run a cargo check. We assert that:
|
|
///
|
|
/// 1. The `shell` tool was actually used (tool_calls_started recorded it),
|
|
/// 2. The agent's final response mentions `megaclaw`,
|
|
/// 3. (live only) The LLM judge signs off on the scenario.
|
|
///
|
|
/// What we *don't* assert (and why): the exact cargo check outcome.
|
|
/// Cloning the full ironclaw workspace and building it from scratch
|
|
/// inside a cold container is minutes of work with many non-deterministic
|
|
/// network steps (crates.io, git submodules, rustup). The goal of this
|
|
/// test is to prove the sandbox plumbing works end-to-end; cargo check's
|
|
/// success/failure is incidental, and the agent's summary is what we
|
|
/// verify. Phase 7 polish can add a "persistence across stop/start"
|
|
/// assertion once the idle reaper lands.
|
|
#[tokio::test]
|
|
#[ignore] // Live tier: needs Docker + sandbox image + (in live mode) LLM keys
|
|
async fn sandbox_clones_ironclaw_and_renames_to_megaclaw() {
|
|
// Skip cleanly when the test environment can't run this scenario.
|
|
// These are not failures — the test is opt-in and requires setup.
|
|
if !docker_reachable().await {
|
|
skip!(
|
|
"Docker is not reachable. Install Docker Desktop / OrbStack / \
|
|
colima, or set DOCKER_HOST to a running daemon."
|
|
);
|
|
}
|
|
let image = std::env::var("IRONCLAW_SANDBOX_IMAGE")
|
|
.unwrap_or_else(|_| "ironclaw/sandbox:dev".to_string());
|
|
if !sandbox_image_present(&image).await {
|
|
skip!(
|
|
"Sandbox image '{image}' not found locally. Build it once with:\n\
|
|
\n docker build -f crates/Dockerfile.sandbox -t {image} .\n"
|
|
);
|
|
}
|
|
|
|
// Replay mode needs a committed trace fixture; live mode needs LLM
|
|
// credentials in `~/.ironclaw/.env`. Skip cleanly when neither is
|
|
// available so a dev running the test for the first time gets a
|
|
// helpful hint instead of a panic deep inside the replay harness.
|
|
let live_mode = std::env::var("IRONCLAW_LIVE_TEST")
|
|
.ok()
|
|
.filter(|v| !v.is_empty() && v != "0")
|
|
.is_some();
|
|
let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures/llm_traces/live/sandbox_clones_ironclaw_to_megaclaw.json");
|
|
if !live_mode && !fixture.exists() {
|
|
skip!(
|
|
"No trace fixture at {} and IRONCLAW_LIVE_TEST is not set. \
|
|
Record it once with:\n\n \
|
|
SANDBOX_ENABLED=true IRONCLAW_LIVE_TEST=1 \\\n \
|
|
cargo test --features libsql --test sandbox_live_e2e -- --ignored --nocapture\n\n\
|
|
(requires LLM credentials in ~/.ironclaw/.env)",
|
|
fixture.display()
|
|
);
|
|
}
|
|
|
|
// Force SANDBOX_ENABLED on for this test so the router wires the
|
|
// containerized mount factory. Serialized via ENV_MUTEX so parallel
|
|
// `--ignored` tests don't race on process-wide env mutation.
|
|
//
|
|
// SAFETY: test-only process-wide env mutation; see the Rust 1.80
|
|
// unsafe-env guidance. The mutex prevents concurrent mutation.
|
|
// The guard is dropped before the first `.await` to satisfy clippy.
|
|
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
let prev_sandbox_val = std::env::var("SANDBOX_ENABLED").ok();
|
|
{
|
|
let _env_guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
|
unsafe {
|
|
std::env::set_var("SANDBOX_ENABLED", "true");
|
|
}
|
|
}
|
|
|
|
let harness = LiveTestHarnessBuilder::new("sandbox_clones_ironclaw_to_megaclaw")
|
|
.with_engine_v2(true)
|
|
.with_auto_approve_tools(true)
|
|
.with_max_tool_iterations(60)
|
|
.build()
|
|
.await;
|
|
|
|
// The prompt is written to be specific enough that the agent takes
|
|
// deterministic tool actions (git clone, sed, cargo check) while
|
|
// leaving enough room for LLM variation that the test doesn't
|
|
// over-constrain the path. The `/project` prefix matches the
|
|
// sandbox mount the backend bind-mounts from the host workspace.
|
|
let user_input = "\
|
|
You are running inside a sandboxed environment with a writable \
|
|
/project directory that persists on the host. Do the following \
|
|
using the `shell` tool — do NOT use read_file/write_file. \
|
|
Be concise and batch commands when it helps:\n\
|
|
\n\
|
|
1. Clone https://github.com/nearai/ironclaw into /project/repo \
|
|
with a shallow clone: \
|
|
`git clone --depth 1 https://github.com/nearai/ironclaw /project/repo`\n\
|
|
2. Rename the project from 'ironclaw' to 'megaclaw' in the \
|
|
top-level Cargo.toml by updating only the line that reads \
|
|
`name = \"ironclaw\"` to `name = \"megaclaw\"`. Use sed:\n \
|
|
`sed -i 's/^name = \"ironclaw\"$/name = \"megaclaw\"/' /project/repo/Cargo.toml`\n\
|
|
3. Verify the rename by running \
|
|
`grep -n 'name = \"megaclaw\"' /project/repo/Cargo.toml` and \
|
|
confirm it prints the renamed line.\n\
|
|
\n\
|
|
When the grep succeeds, summarize the three steps you took and \
|
|
include the grep output verbatim as proof. Stop there — no \
|
|
further verification needed.";
|
|
|
|
let rig = harness.rig();
|
|
rig.send_message(user_input).await;
|
|
|
|
// Wall clock budget: cold container start (~1s) + shallow clone
|
|
// (~10-30s on a fast link) + sed (~0.1s) + grep (~0.1s) + LLM
|
|
// turns (~30-60s). 5 minutes is generous but bounded.
|
|
let responses = rig.wait_for_responses(1, Duration::from_secs(300)).await;
|
|
assert!(!responses.is_empty(), "Expected at least one response");
|
|
|
|
let text: Vec<String> = responses.iter().map(|r| r.content.clone()).collect();
|
|
let tools = rig.tool_calls_started();
|
|
|
|
eprintln!("[SandboxE2E] Tools used: {tools:?}");
|
|
eprintln!(
|
|
"[SandboxE2E] Response preview: {}",
|
|
text.join("\n").chars().take(600).collect::<String>()
|
|
);
|
|
|
|
// Assertion 1: shell ran. Without this the sandbox was never
|
|
// exercised and the test is vacuous. Tool names in the status
|
|
// stream are formatted as `"shell(preview)"` — match by prefix so
|
|
// both forms work.
|
|
assert!(
|
|
tools
|
|
.iter()
|
|
.any(|t| t == "shell" || t.starts_with("shell(")),
|
|
"Expected the shell tool to run inside the sandbox, but no \
|
|
shell call was recorded. Tools: {tools:?}"
|
|
);
|
|
|
|
// Assertion 2: the agent's summary mentions megaclaw. This is the
|
|
// cheap proof that it performed (and acknowledged) the rename.
|
|
let joined = text.join("\n").to_lowercase();
|
|
assert!(
|
|
joined.contains("megaclaw"),
|
|
"Agent summary should mention 'megaclaw' but did not. \
|
|
Full response: {joined}"
|
|
);
|
|
|
|
// Assertion 3 (live only): LLM judge signs off on all three steps.
|
|
// This catches the case where the agent claimed success but skipped
|
|
// a step, because the judge reads the full response with fresh eyes.
|
|
if let Some(verdict) = harness.judge(&text, JUDGE_CRITERIA).await {
|
|
assert!(
|
|
verdict.pass,
|
|
"LLM judge rejected the scenario: {}",
|
|
verdict.reasoning
|
|
);
|
|
}
|
|
|
|
harness.finish(user_input, &text).await;
|
|
|
|
// Restore the original env var value so later tests in the same
|
|
// process see the state they started with.
|
|
{
|
|
let _env_guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
|
|
unsafe {
|
|
match prev_sandbox_val {
|
|
Some(v) => std::env::set_var("SANDBOX_ENABLED", v),
|
|
None => std::env::remove_var("SANDBOX_ENABLED"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|