diff --git a/.github/workflows/centaur-ci.yml b/.github/workflows/centaur-ci.yml index 2de157a1..7da253ee 100644 --- a/.github/workflows/centaur-ci.yml +++ b/.github/workflows/centaur-ci.yml @@ -315,7 +315,7 @@ jobs: working-directory: runtime/node-sync run: | sudo -E env "PATH=$PATH" CARGO_TARGET_DIR=/tmp/privileged-target \ - cargo test --test git_identity_overlay_visibility -- --nocapture + cargo test --test git_identity_visibility -- --nocapture # Real overlay + scan-demo: locks in the syscall proofs (whiteout→Delete, # symlink→metadata-only, openat2 NO_SYMLINKS blocks the /etc/shadow escape). diff --git a/centaur/services/sandbox/Dockerfile b/centaur/services/sandbox/Dockerfile index de8d68b6..c24b3f1b 100644 --- a/centaur/services/sandbox/Dockerfile +++ b/centaur/services/sandbox/Dockerfile @@ -250,7 +250,7 @@ RUN mkdir -p ~/.config/amp ~/.codex ~/.pi/agent ~/repos ~/workspace \ && git config --global --add safe.directory '*' \ && git config --global user.name "Centaur AI" \ && git config --global user.email "ai@centaur.local" \ - && git config --global include.path /home/agent/.config/git/atrium-identity + && git config --global include.path /home/agent/context/.atrium-git-identity # ============================================================================== # Final thin stage: only frequently-changing local files below. diff --git a/runtime/node-sync/CONTRACT.md b/runtime/node-sync/CONTRACT.md index fcf92907..e2c05b87 100644 --- a/runtime/node-sync/CONTRACT.md +++ b/runtime/node-sync/CONTRACT.md @@ -143,6 +143,21 @@ that reaches a claimed warm pod. On 204 the daemon writes nothing and the image's baked `Centaur AI` identity stands — that fallback is the pre-existing behavior, which is what makes this lane safe to ship dark. +**The identity file MUST land in the session's context root** (`~/context/.atrium-git-identity`, +the ext4 bind mount) and never in the agent's overlay home. This is load-bearing, not +stylistic, and it shipped wrong twice before anyone measured it. `entrypoint.sh` runs +`git config` at POD CREATION; git resolves the `[include]` target, gets ENOENT, and the +kernel caches a NEGATIVE dentry minutes before the claim that knows who the user is. So +the lookup has always already missed by the time the identity is written, and the only +question is whether that negative entry is invalidated. Overlay mount instances keep +independent dentry trees, so a node-side create — into `upper` OR through `merged`, both +were shipped — never invalidates the pod's entry: readdir lists the file while lookup +returns ENOENT, and the agent commits as `Centaur AI` with the file plainly on disk. A +bind mount of one ext4 superblock shares the dentry tree, so the create is observed even +after a failed lookup. `tests/git_identity_visibility.rs` pins exactly that ordering +(mount, MISS, write, hit); a test that writes before looking up passes on the broken +design too. + Lanes covered by route-existence + daemon-side parsing only (their payloads are opaque blobs or one-way writes): `artifacts/raw`, `harness-transcript`, `harness-state-bundle`, `profile-candidates`, `profile-baseline`, diff --git a/runtime/node-sync/src/bin/centaur-node-syncd/linux_daemon/mod.rs b/runtime/node-sync/src/bin/centaur-node-syncd/linux_daemon/mod.rs index 1fc4991c..9ff1a1dc 100644 --- a/runtime/node-sync/src/bin/centaur-node-syncd/linux_daemon/mod.rs +++ b/runtime/node-sync/src/bin/centaur-node-syncd/linux_daemon/mod.rs @@ -434,36 +434,46 @@ fn run_multi_session(global: GlobalConfig, overlays_root: PathBuf, once: bool, i continue; } }; + let session = session_config_from_discovered(&discovered, &mounted); + mounted_overlays.insert(discovered.session.clone(), mounted); + let first_seen = !states.contains_key(&session.session); + let atrium_root = scoped_atrium_root(&global.atrium_root, &session.session); + if should_eager_poll_atrium(&session, &atrium_root) { + eager_poll_sessions.insert(session.session.clone()); + } // Git author identity is server-derived per CLAIM, and a claim is a // mount — so refresh it only when the mount is being (re)established, // never on every tick. An unconditional per-session GET per tick is // exactly the steady-state cost the atrium delta protocol exists to // avoid. // - // This MUST write through `mounted.merged`, never `discovered.upper`. - // A warm pod's overlay is already mounted long before the claim that - // gives it an identity, and modifying an overlay's upperdir behind a - // live mount is undefined: the file lands on disk but the merged view - // keeps serving its cached (negative) dentry, so the agent never sees - // it — and the path is left so incoherent that a later mkdir through - // the mount fails with ESTALE. Shipped exactly that way once: the - // identity file was present in the upper and the agent still committed - // as "Centaur AI". Same hazard the lowerdir comment above describes. + // It lands in the CONTEXT root — an ext4 bind mount — and NOT in the + // agent's overlay home, which is why `/opt/centaur/gitconfig` includes + // `~/context/.atrium-git-identity`. This is not a stylistic choice; the + // home cannot work: + // + // entrypoint.sh runs `git config` at POD CREATION. git reads the + // global config, sees the [include], opens the target, gets ENOENT, + // and the kernel caches a NEGATIVE dentry for that path — minutes + // before any claim exists. Overlay mount INSTANCES keep independent + // dentry trees, so a later create from the node side (upper OR merged) + // never invalidates the pod's negative entry: readdir lists the file + // while lookup still returns ENOENT, and the agent commits as the + // baked "Centaur AI". Shipped that way twice. A bind mount of one ext4 + // superblock shares the dentry tree, so the create IS observed even + // after a failed lookup — measured on a live pod, both ways. // // Failures here must NOT skip the mount. The image bakes a - // "Centaur AI" identity and /opt/centaur/gitconfig includes this file - // only if it exists, so an absent identity degrades to exactly the - // pre-existing behavior. Gating the mount on identity would turn a - // surface blip into "the agent's home never mounts" — trading a - // recoverable misattributed commit for an unrecoverable dead session. + // "Centaur AI" identity and only includes this file if it exists, so an + // absent identity degrades to exactly the pre-existing behavior. Gating + // the mount on identity would turn a surface blip into "the agent's home + // never mounts" — trading a recoverable misattributed commit for an + // unrecoverable dead session. // - // Ordering caveat: mount_overlay writes the ready marker, and a warm - // pod's marker predates this claim entirely, so there is no - // write-before-ready guarantee to be had here — the agent could in - // principle commit in the seconds before this lands. That degrades to - // the baked identity rather than a wrong one, which is why it is - // acceptable; closing it for real needs the claim path itself to carry - // the identity. + // Ordering caveat: a warm pod's ready marker predates this claim, so + // there is no write-before-ready guarantee — the agent could commit in + // the seconds before this lands, degrading to the baked identity rather + // than a wrong one. Closing that needs the claim path to carry it. if wip_remounted { let identity_client = HttpAtriumClient::new( &global.base_url, @@ -473,7 +483,7 @@ fn run_multi_session(global: GlobalConfig, overlays_root: PathBuf, once: bool, i match identity_client.get_git_identity() { Ok(identity) => { if let Err(error) = materialize_git_identity( - &mounted, + &atrium_root, identity.as_ref(), Some(discovered.manifest.agent_uid), ) { @@ -491,13 +501,6 @@ fn run_multi_session(global: GlobalConfig, overlays_root: PathBuf, once: bool, i } } } - let session = session_config_from_discovered(&discovered, &mounted); - mounted_overlays.insert(discovered.session.clone(), mounted); - let first_seen = !states.contains_key(&session.session); - let atrium_root = scoped_atrium_root(&global.atrium_root, &session.session); - if should_eager_poll_atrium(&session, &atrium_root) { - eager_poll_sessions.insert(session.session.clone()); - } if first_seen && let Err(error) = write_mount_readme(&atrium_root) { eprintln!( "session {}: seed Atrium context README: {error}", diff --git a/runtime/node-sync/src/materializer.rs b/runtime/node-sync/src/materializer.rs index 618846a6..f5e9bfc6 100644 --- a/runtime/node-sync/src/materializer.rs +++ b/runtime/node-sync/src/materializer.rs @@ -6,7 +6,6 @@ use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use crate::http_client::GitIdentity; -use crate::overlay_mount::OverlayMountPlan; use crate::runtime::{AtriumChannel, AtriumClient, ContextDeltaRequest, ContextDocResponse}; use crate::state::{ContextDocState, DaemonState}; @@ -86,26 +85,31 @@ If a listed path is missing, it is still materializing — wait a few seconds an "#; pub const CONTEXT_READY_MARKER: &str = ".atrium-context-ready"; -pub const GIT_IDENTITY_RELATIVE_PATH: &str = ".config/git/atrium-identity"; +/// Name of the identity file inside a session's CONTEXT root. The sandbox image +/// includes `~/context/.atrium-git-identity` from /opt/centaur/gitconfig. Dotfile so +/// it stays out of the context doc tree the agent browses. +pub const GIT_IDENTITY_FILE: &str = ".atrium-git-identity"; -/// Authoritatively materialize the server-derived identity into a session's home. +/// Authoritatively materialize the server-derived identity into a session's CONTEXT +/// root — the ext4 bind mount the agent sees at `~/context`, never its overlay home. /// `None` is the 204 fallback and removes a prior identity so the image default wins. /// -/// Takes the mounted `OverlayMountPlan` rather than a bare path ON PURPOSE: this must -/// be written through `plan.merged`, and an earlier version took a `&Path` and got -/// handed the overlay's `upper` instead. That compiled, passed every tempdir unit -/// test, and was silently useless in production — a warm pod's overlay is already -/// mounted long before the claim that gives it an identity, and writing to an -/// upperdir behind a live mount is undefined: the file lands on disk while the merged -/// view keeps serving its cached negative dentry, so the agent never sees it and -/// commits as the baked "Centaur AI". Deriving the path here instead of accepting one -/// makes that mistake unrepresentable rather than merely documented. +/// `context_root` must be the same value `write_mount_readme` is given +/// (`scoped_atrium_root(...)`). Passing an overlay path here is the bug this function +/// has already shipped twice: `entrypoint.sh` runs `git config` at POD CREATION, git +/// resolves the `[include]` target, gets ENOENT, and the kernel caches a NEGATIVE +/// dentry for that path minutes before any claim. Overlay mount instances keep +/// independent dentry trees, so a later create from the node side — upper OR merged — +/// never invalidates the pod's negative entry, and the agent commits as the baked +/// "Centaur AI" while the file plainly exists on disk. A bind mount of one ext4 +/// superblock shares the dentry tree, so the create is observed even after a failed +/// lookup. Measured on a live pod, both ways; see tests/git_identity_visibility.rs. pub fn materialize_git_identity( - plan: &OverlayMountPlan, + context_root: &Path, identity: Option<&GitIdentity>, agent_uid: Option, ) -> Result<(), String> { - let dst = plan.merged.join(GIT_IDENTITY_RELATIVE_PATH); + let dst = context_root.join(GIT_IDENTITY_FILE); let Some(identity) = identity else { remove_file_if_present(&dst)?; return Ok(()); @@ -999,17 +1003,6 @@ mod tests { } } - // Build via the real constructor so the plan cannot drift from production's shape. - // `merged` is a plain tempdir here: these unit tests cover the FILE contract - // (escaping, idempotency, 204-removal). They CANNOT cover overlay visibility, which - // is what actually broke in production — see tests/git_identity_overlay_visibility.rs. - fn plan_for(home: &Path) -> OverlayMountPlan { - let overlays_root = home.join("overlays-root"); - std::fs::create_dir_all(&overlays_root).unwrap(); - crate::overlay_mount::plan_overlay_mount(&overlays_root, "unit", home, "", &[], None) - .unwrap() - } - // Pinned to `/` for the same reason the writer is: git discovers a repository from // the cwd even for `--file` reads, so running the suite from inside a linked // worktree (whose .git is a gitfile) would fail these on repo discovery, not on @@ -1027,11 +1020,11 @@ mod tests { #[test] fn git_identity_204_removes_a_stale_file() { let temp = tempfile::tempdir().unwrap(); - let dst = temp.path().join(GIT_IDENTITY_RELATIVE_PATH); + let dst = temp.path().join(GIT_IDENTITY_FILE); std::fs::create_dir_all(dst.parent().unwrap()).unwrap(); std::fs::write(&dst, b"stale").unwrap(); - materialize_git_identity(&plan_for(temp.path()), None, None).unwrap(); + materialize_git_identity(temp.path(), None, None).unwrap(); assert!(!dst.exists()); } @@ -1040,9 +1033,9 @@ mod tests { fn git_identity_200_writes_exact_config() { let temp = tempfile::tempdir().unwrap(); let identity = git_identity("Allan Niemerg"); - let dst = temp.path().join(GIT_IDENTITY_RELATIVE_PATH); + let dst = temp.path().join(GIT_IDENTITY_FILE); - materialize_git_identity(&plan_for(temp.path()), Some(&identity), None).unwrap(); + materialize_git_identity(temp.path(), Some(&identity), None).unwrap(); assert_eq!( std::fs::read_to_string(&dst).unwrap(), @@ -1063,9 +1056,9 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let hostile = "Eve \"quoted\" \\\\ path\n[include]\n\tpath = /etc/passwd"; let identity = git_identity(hostile); - let dst = temp.path().join(GIT_IDENTITY_RELATIVE_PATH); + let dst = temp.path().join(GIT_IDENTITY_FILE); - materialize_git_identity(&plan_for(temp.path()), Some(&identity), None).unwrap(); + materialize_git_identity(temp.path(), Some(&identity), None).unwrap(); let output = git_config_get(&dst, "user.name"); assert!(output.status.success()); @@ -1084,11 +1077,11 @@ mod tests { fn git_identity_materialization_is_idempotent() { let temp = tempfile::tempdir().unwrap(); let identity = git_identity("Allan Niemerg"); - let dst = temp.path().join(GIT_IDENTITY_RELATIVE_PATH); + let dst = temp.path().join(GIT_IDENTITY_FILE); - materialize_git_identity(&plan_for(temp.path()), Some(&identity), None).unwrap(); + materialize_git_identity(temp.path(), Some(&identity), None).unwrap(); let first = std::fs::read(&dst).unwrap(); - materialize_git_identity(&plan_for(temp.path()), Some(&identity), None).unwrap(); + materialize_git_identity(temp.path(), Some(&identity), None).unwrap(); assert_eq!(std::fs::read(&dst).unwrap(), first); } diff --git a/runtime/node-sync/tests/git_identity_overlay_visibility.rs b/runtime/node-sync/tests/git_identity_overlay_visibility.rs deleted file mode 100644 index 7091fca9..00000000 --- a/runtime/node-sync/tests/git_identity_overlay_visibility.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Pins the one thing a tempdir test can never see: the git author identity has to -//! be visible to the AGENT, which reads it through the overlay's merged view. -//! -//! We shipped this wrong once. The daemon wrote the identity into the overlay's -//! upperdir before mounting, which is fine for a cold create but silently useless -//! for a warm claim: a warm pod's overlay is already mounted minutes before the -//! claim that gives it an identity, and modifying an upperdir behind a live mount -//! is undefined behaviour. The file landed on disk, `ls` on the upper showed it, -//! every unit test passed — and the agent still committed as "Centaur AI", because -//! the merged view kept serving its cached negative dentry. The upper was left so -//! incoherent that a later mkdir through the mount failed with ESTALE. -//! -//! So: assert against the MERGED path, on a real overlay, with the mount already -//! established — the warm-claim shape. A unit test on a plain tempdir passes either -//! way and proves nothing. -//! -//! Needs Linux + root + a non-overlayfs TMPDIR (overlay upperdirs cannot live on -//! overlayfs; in a container, mount a tmpfs and point TMPDIR at it). Skips silently -//! otherwise, same as the other privileged on-node validations. - -#![cfg(target_os = "linux")] - -use std::fs; -use std::process::Command; - -use centaur_node_sync::http_client::GitIdentity; -use centaur_node_sync::materializer::{GIT_IDENTITY_RELATIVE_PATH, materialize_git_identity}; -use centaur_node_sync::overlay_mount::{mount_overlay, plan_overlay_mount, unmount_overlay}; - -fn identity() -> GitIdentity { - // `source` rides the wire for observability but the daemon is a tolerant reader - // and never needs it, so it is deliberately absent from this struct. - GitIdentity { - author_name: "Gary".to_string(), - author_email: "10901359+gbasin@users.noreply.github.com".to_string(), - session_id: "2f30f3db-e964-4e25-9371-7d323836c1c7".to_string(), - harness: "codex".to_string(), - } -} - -fn git_get(file: &std::path::Path, key: &str) -> String { - let out = Command::new("git") - .current_dir("/") - .args(["config", "--file"]) - .arg(file) - .args(["--get", key]) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -/// The warm-claim shape: overlay ALREADY mounted, then the identity arrives. -#[test] -fn identity_written_after_mount_is_visible_through_the_merged_view() { - if unsafe { libc::geteuid() } != 0 { - eprintln!( - "SKIP: identity_written_after_mount_is_visible_through_the_merged_view requires root" - ); - return; - } - - let session = "git-identity-visibility-it"; - let root = std::env::temp_dir().join(format!("gid-it-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - let overlays_root = root.join("overlays"); - let merged = root.join("merged").join(session); - fs::create_dir_all(&overlays_root).unwrap(); - fs::create_dir_all(&merged).unwrap(); - - let plan = plan_overlay_mount(&overlays_root, session, &merged, "", &[], None).unwrap(); - let mounted = mount_overlay(plan, None).expect("overlay must mount"); - - // The agent is already live against this mount at this point — exactly the warm - // pod that has been sitting in the pool. NOW the claim's identity shows up. - materialize_git_identity(&mounted, Some(&identity()), None).expect("materialize"); - - let seen_by_agent = mounted.merged.join(GIT_IDENTITY_RELATIVE_PATH); - assert!( - seen_by_agent.exists(), - "identity is invisible through the merged view — the agent would commit as the baked \ - image identity. This is the exact bug that shipped: writing via the upperdir of a live \ - overlay leaves the file on disk but out of the agent's view." - ); - assert_eq!( - git_get(&seen_by_agent, "user.email"), - "10901359+gbasin@users.noreply.github.com" - ); - assert_eq!(git_get(&seen_by_agent, "user.name"), "Gary"); - // The [atrium] block feeds the commit-msg hook's provenance trailers; it travels - // in the same file, so emit both or neither. - assert_eq!( - git_get(&seen_by_agent, "atrium.sessionId"), - "2f30f3db-e964-4e25-9371-7d323836c1c7" - ); - assert_eq!(git_get(&seen_by_agent, "atrium.harness"), "codex"); - - // A 204 must clear it through the same view, so a revoked identity cannot linger - // and keep authoring as someone who no longer resolves. - materialize_git_identity(&mounted, None, None).expect("204 removes"); - assert!( - !seen_by_agent.exists(), - "a 204 must remove the identity through the merged view, not just the upper" - ); - - unmount_overlay(&mounted).unwrap(); - let _ = fs::remove_dir_all(&root); -} diff --git a/runtime/node-sync/tests/git_identity_visibility.rs b/runtime/node-sync/tests/git_identity_visibility.rs new file mode 100644 index 00000000..174cbc7c --- /dev/null +++ b/runtime/node-sync/tests/git_identity_visibility.rs @@ -0,0 +1,130 @@ +//! Pins the property the git-identity design rests on: the agent must SEE the identity +//! after its own earlier lookup already missed. +//! +//! This is not a hypothetical ordering. `entrypoint.sh` runs `git config` at POD +//! CREATION; git reads /opt/centaur/gitconfig, resolves the `[include]` target, gets +//! ENOENT, and the kernel caches a NEGATIVE dentry for that path — minutes before the +//! claim that even knows who the user is. So by the time node-sync writes the identity, +//! the lookup has ALREADY missed. Whether the agent ever sees the file is decided +//! entirely by whether that negative entry gets invalidated. +//! +//! It does not on the agent's overlay home: overlay mount INSTANCES keep independent +//! dentry trees, so a create from the node side (upper OR merged — both were shipped, +//! both failed) leaves the pod's negative entry intact. The forensic signature is +//! readdir listing the file while lookup returns ENOENT. +//! +//! It does on a bind mount of a single ext4 superblock, which shares one dentry tree — +//! and that is exactly what `~/context` is. Hence the identity lives in the context +//! root. Measured on a live prod pod, both ways, before this was written. +//! +//! So the assertion here is deliberately shaped like the bug: mount, MISS, write, hit. +//! A test that writes before looking up passes on both the broken and fixed designs and +//! proves nothing — that is precisely how this shipped twice. +//! +//! Needs Linux + root (bind mounts). Skips silently otherwise, same as the other +//! privileged on-node validations. CI runs it under sudo — see centaur-ci.yml; the +//! unprivileged `cargo test` lane reports this file as "ok" while skipping every case. + +#![cfg(target_os = "linux")] + +use std::fs; +use std::path::Path; +use std::process::Command; + +use centaur_node_sync::http_client::GitIdentity; +use centaur_node_sync::materializer::{GIT_IDENTITY_FILE, materialize_git_identity}; + +fn identity() -> GitIdentity { + GitIdentity { + author_name: "Gary".to_string(), + author_email: "10901359+gbasin@users.noreply.github.com".to_string(), + session_id: "a5aabc97-3382-4ab8-b84c-1a1c2a4f7013".to_string(), + harness: "codex".to_string(), + } +} + +fn git_get(file: &Path, key: &str) -> String { + let out = Command::new("git") + .current_dir("/") + .args(["config", "--file"]) + .arg(file) + .args(["--get", key]) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() +} + +fn is_root() -> bool { + unsafe { libc::geteuid() == 0 } +} + +/// node-sync writes into the context root; the agent reads the same bytes through the +/// bind mount at ~/context — even though it already looked and found nothing. +#[test] +fn identity_is_visible_through_the_bind_mount_after_a_failed_lookup() { + if !is_root() { + eprintln!( + "SKIP: identity_is_visible_through_the_bind_mount_after_a_failed_lookup requires root" + ); + return; + } + + let root = std::env::temp_dir().join(format!("gid-bind-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + // `node_side` is what the daemon writes (scoped_atrium_root); `pod_side` is what the + // agent reads (~/context). One superblock, two mount points — the real topology. + let node_side = root.join("var-lib-centaur-atrium-sess"); + let pod_side = root.join("home-agent-context"); + fs::create_dir_all(&node_side).unwrap(); + fs::create_dir_all(&pod_side).unwrap(); + + let status = Command::new("mount") + .args(["--bind"]) + .arg(&node_side) + .arg(&pod_side) + .status() + .unwrap(); + assert!(status.success(), "bind mount must succeed"); + + let seen_by_agent = pod_side.join(GIT_IDENTITY_FILE); + + // THE POISON STEP: the agent (via git's include resolution at pod creation) looks + // first and misses. Everything after this is only meaningful because of this line. + assert!( + !seen_by_agent.exists(), + "precondition: the agent's lookup must miss before the identity is written" + ); + + materialize_git_identity(&node_side, Some(&identity()), None).expect("materialize"); + + assert!( + seen_by_agent.exists(), + "identity written to the context root is invisible through the bind mount after a \ + failed lookup — the agent would commit as the baked image identity. If this fails, \ + the identity was moved onto a mount whose dentry tree the agent does not share \ + (an overlay home); see the doc comment on materialize_git_identity." + ); + assert_eq!( + git_get(&seen_by_agent, "user.email"), + "10901359+gbasin@users.noreply.github.com" + ); + assert_eq!(git_get(&seen_by_agent, "user.name"), "Gary"); + // The [atrium] block feeds the commit-msg hook's provenance trailers; same file, so + // emit both or neither. + assert_eq!( + git_get(&seen_by_agent, "atrium.sessionId"), + "a5aabc97-3382-4ab8-b84c-1a1c2a4f7013" + ); + assert_eq!(git_get(&seen_by_agent, "atrium.harness"), "codex"); + + // A 204 must clear it through the same view, so a revoked identity cannot keep + // authoring as someone who no longer resolves. + materialize_git_identity(&node_side, None, None).expect("204 removes"); + assert!( + !seen_by_agent.exists(), + "a 204 must remove the identity as the agent sees it, not just on the node" + ); + + let _ = Command::new("umount").arg(&pod_side).status(); + let _ = fs::remove_dir_all(&root); +}