test(tui): unshadow sandbox fixtures and break the workshop/env lock cycle

Two independent test-infrastructure defects.

#6305 — the Linux bwrap wrapper mounts a fresh `--tmpfs /tmp` before it
binds the policy's writable roots, and an enforced read-only command has
no writable roots at all, so nothing re-exposes the host `/tmp`. A fixture
workspace rooted there is shadowed inside the sandbox and the trailing
`--chdir <workspace>` lands on a path that no longer exists
(`bwrap: Can't chdir to /tmp/.tmpXXXXXX`). The tmpfs is deliberate
isolation and a security boundary, so the fixture moves instead of the
mount: `test_support::sandbox_visible_tempdir()` roots it under this
crate's `OUT_DIR` (the Cargo target directory, not `TMPDIR`) and fails
loudly if that itself is under `/tmp`. Applied to the reported delivery
probe and to the two `enforced_readonly` native probes, which have the
same shape — one of them was passing only because the bwrap chdir failure
happened to satisfy its negative assertion.

#6306 — the two `declared_model_posts_*` tests were victims, not the
cause. `active_workshop_test_guard()` takes a process-wide serial mutex
and its one caller holds it across `reload_config`, which loads settings
and reads the environment through `test_support::with_test_env_lock`; a
test that seals the environment *first* and then reaches
`WorkshopConfig::install_active` through that same `reload_config` takes
the two locks in the opposite order. Confirmed by sampling the wedged
binary: `http_and_web_server_thread_manager_installs_configured_workshop_byte_budgets`
held the workshop gate and was parked in `lock_test_env`, while
`scoped_runtime_selections_leave_device_memory_unchanged` held the env
barrier and was parked at `large_output_router.rs:121` on the workshop
gate — with every other `config` test queued behind the barrier. The
guard now takes the env barrier before the gate, the same shape as
`provider_lake::lock_live_snapshot`, which makes the inversion impossible
for every caller at once.

Validation (macOS, aarch64, lib test binary):
- before the #6306 fix: `config` filter wedged; 17 tests reporting
  "has been running for over 60 seconds"; killed after ~10 minutes.
- after: `test result: ok. 1054 passed; 0 failed; 1 ignored; 0 measured;
  12015 filtered out; finished in 8.56s` — reproduced stable over three
  further runs (8.35s / 8.37s / 8.35s).
- `enforced_readonly`: `test result: ok. 7 passed; 0 failed` with real
  NATIVE_READONLY_ENFORCED receipts (macOS seatbelt), not UNRUN.
- `large_output_router` 5 passed, `workshop` 3 passed, `test_env_lock`
  2 passed, `sandbox` 123 passed, `tools::subagent::delivery_tests`
  19 passed; all 0 failed.
- `cargo fmt --all -- --check` clean.

Not verified: #6305 is Linux/bwrap-only and cannot be exercised on this
macOS host. The relocation is argued against the argv built in
`sandbox/bwrap.rs` (`--tmpfs /tmp` precedes the writable binds, and
`safe_mounts` yields none for a read-only command) and is proven only to
keep the macOS seatbelt probes passing from the new root.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5 committed 2026-09-21 13:09:09 -07:00
1 parent 2f47a370f4
commit 4e27b4ee17
4 files changed
+90 -4

No files matched your search

+50
View File
@@ -81,6 +81,56 @@ thread_local! {
static HOLDER_ROOT: OnceLock<PathBuf> = const { OnceLock::new() };
}
/// A fixture workspace an OS sandbox can still see (#6305).
///
/// The Linux bwrap wrapper mounts a fresh `--tmpfs /tmp` before it binds the
/// policy's writable roots (`sandbox/bwrap.rs`), and an enforced read-only
/// command has no writable roots at all — nothing re-exposes the host `/tmp`.
/// A fixture rooted there is shadowed inside the sandbox, so the trailing
/// `--chdir <workspace>` lands on a path that no longer exists and bwrap exits
/// with `Can't chdir to /tmp/.tmpXXXXXX`. The tmpfs is deliberate isolation and
/// a security boundary, so the fixture moves instead of the mount.
///
/// Known limitations: this relocates only the directory the sandboxed command
/// chdirs into. It does not make anything else under the host `/tmp` reachable
/// from inside the sandbox, and it says nothing about `/dev` or `/proc`, which
/// bwrap also replaces. Use it for the sandbox probes; plain `tempfile::tempdir`
/// stays correct everywhere else.
pub(crate) fn sandbox_visible_tempdir() -> tempfile::TempDir {
let root = sandbox_visible_fixture_root();
tempfile::tempdir_in(root).unwrap_or_else(|error| {
panic!(
"failed to create a sandbox-visible fixture workspace in {}: {error}",
root.display()
)
})
}
/// `OUT_DIR` is this crate's own build directory, so it follows the Cargo
/// target directory rather than `TMPDIR` — the one path every test binary
/// already owns and that is outside `/tmp` in every normal layout. A target
/// directory deliberately placed under `/tmp` would silently reintroduce
/// #6305, so say so instead of handing back a shadowed path.
fn sandbox_visible_fixture_root() -> &'static Path {
static ROOT: OnceLock<PathBuf> = OnceLock::new();
ROOT.get_or_init(|| {
let root = Path::new(env!("OUT_DIR")).join("sandbox-fixtures");
assert!(
!root.starts_with("/tmp"),
"sandbox fixtures need a root outside /tmp, which bwrap replaces with a fresh \
tmpfs: {} is under it. Point CARGO_TARGET_DIR somewhere else.",
root.display()
);
std::fs::create_dir_all(&root).unwrap_or_else(|error| {
panic!(
"failed to create the sandbox fixture root {}: {error}",
root.display()
)
});
root
})
}
/// Build a syntactically valid, non-secret JWT fixture without embedding a
/// high-entropy token-shaped literal in Git history.
pub(crate) fn future_test_jwt(label: &str) -> String {
+31 -1
View File
@@ -48,9 +48,15 @@ std::thread_local! {
/// Holds every test-side workshop activation behind one process-wide gate.
/// The thread-local marker lets the owning current-thread test call
/// `install_active` without trying to acquire its own non-reentrant lock.
///
/// The guard also owns the process-wide test env barrier, because holding this
/// gate across a `reload_config` is holding it across `Settings::load` and
/// `Config`'s env reads, which take that barrier (#6306). Field order is the
/// release order: the serial gate first, then the barrier.
#[cfg(test)]
pub(crate) struct ActiveWorkshopTestGuard {
_serial: std::sync::MutexGuard<'static, ()>,
_env: Option<crate::test_support::TestEnvLock>,
}
#[cfg(test)]
@@ -60,18 +66,42 @@ impl Drop for ActiveWorkshopTestGuard {
}
}
/// Take the workshop gate, and the env barrier under it, in that order (#6306).
///
/// A holder of this gate keeps it across whole product calls — `reload_config`
/// installs the budgets and then loads settings — and those calls read the
/// environment through `test_support::with_test_env_lock`. A test that sealed
/// the environment first and then reaches `install_active` through the very
/// same `reload_config` takes the two locks in the opposite order, and the
/// pair wedges the whole binary: `cargo test -p codewhale-tui --lib config`
/// never returned, with dozens of unrelated tests queued on the barrier.
/// Acquiring the barrier here, before the gate, makes that inversion
/// impossible for every caller at once — the same shape as
/// `provider_lake::lock_live_snapshot`.
///
/// Known limitation: this orders these two locks and nothing else. A future
/// process-wide test gate held across product code has to join the same order
/// rather than invent a third one.
#[cfg(test)]
pub(crate) fn active_workshop_test_guard() -> ActiveWorkshopTestGuard {
assert!(
!ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(std::cell::Cell::get),
"active workshop test guard is not reentrant"
);
let env = if crate::test_support::current_thread_holds_test_env_lock() {
None
} else {
Some(crate::test_support::lock_test_env())
};
let serial = ACTIVE_WORKSHOP_TEST_SERIAL
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(|held| held.set(true));
ActiveWorkshopTestGuard { _serial: serial }
ActiveWorkshopTestGuard {
_serial: serial,
_env: env,
}
}
fn active_workshop_slot() -> &'static Mutex<WorkshopConfig> {
@@ -142,7 +142,9 @@ fn python(script: &str) -> String {
#[tokio::test]
#[allow(clippy::print_stderr)] // Native enforcement receipt, outside the TUI runtime.
async fn enforced_readonly_native_python_reads_sqlite_and_cannot_write() {
let tmp = tempdir().unwrap();
// #6305: the sandbox replaces /tmp with a fresh tmpfs, so a fixture
// rooted there vanishes before --chdir reaches it.
let tmp = crate::test_support::sandbox_visible_tempdir();
let Some(context) = native_context(tmp.path()) else {
return;
};
@@ -192,7 +194,9 @@ async fn enforced_readonly_native_python_reads_sqlite_and_cannot_write() {
#[tokio::test]
#[allow(clippy::print_stderr)] // Native enforcement receipt, outside the TUI runtime.
async fn enforced_readonly_native_python_cannot_reach_a_loopback_listener() {
let tmp = tempdir().unwrap();
// #6305: the sandbox replaces /tmp with a fresh tmpfs, so a fixture
// rooted there vanishes before --chdir reaches it.
let tmp = crate::test_support::sandbox_visible_tempdir();
let Some(context) = native_context(tmp.path()) else {
return;
};
@@ -435,7 +435,9 @@ fn disjoint_sibling_write_paths_admit_and_ancestor_overlap_names_actual_remedy()
#[tokio::test]
#[allow(clippy::print_stderr)] // Test receipt must distinguish refused probes from exercised isolation.
async fn enforced_readonly_python_queries_sqlite_under_a_live_peer_write_claim() {
let tmp = tempdir().unwrap();
// #6305: the sandbox replaces /tmp with a fresh tmpfs, so a fixture
// rooted there vanishes before --chdir reaches it.
let tmp = crate::test_support::sandbox_visible_tempdir();
let database = rusqlite::Connection::open(tmp.path().join("fixture.sqlite")).unwrap();
database
.execute_batch(