From 6e5f227fa755b654b468f081af173dd4a0adeff3 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sun, 21 Jun 2026 15:23:28 +0200 Subject: [PATCH 1/3] =?UTF-8?q?docs(handoff):=20mark=20C1=20validated=20?= =?UTF-8?q?=E2=80=94=20apply=20--yes=20+=20idempotent=20re-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSA 2026-06-21: clawdie apply --pool testpool --yes completes all 7 steps (ZFS datasets, _clawdie user, chown, rc.d, sysrc). Idempotent re-run skips user creation via exit 65. C1 is done. --- docs/CLAWDIE-INSTALLER-HANDOFF.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/CLAWDIE-INSTALLER-HANDOFF.md b/docs/CLAWDIE-INSTALLER-HANDOFF.md index 06d6310..e1315ea 100644 --- a/docs/CLAWDIE-INSTALLER-HANDOFF.md +++ b/docs/CLAWDIE-INSTALLER-HANDOFF.md @@ -106,9 +106,14 @@ Not done: no destructive `apply --yes`; still requires scratch pool/VM. - [x] `cargo test -p clawdie` passes on FreeBSD 15 (output + versions reported). - [x] `discover` + `plan` correct against a real FreeBSD ZFS host for read-only/dry-run paths. -- [ ] `apply --yes` on a scratch pool creates the datasets, user, and rc.d service as specified; teardown verified. -- [ ] (if tested) Linux `--create-pool` works on a spare disk and the empty-disk guard refuses non-empty disks. -- [ ] Any FreeBSD-specific differences from the Linux-built behavior are filed as a PR and reported back. +- [x] `apply --yes` on a scratch pool creates the datasets, user, and rc.d service as + specified; teardown verified. (OSA file-backed testpool, 2026-06-21.) +- [x] Idempotent re-run: second `apply --yes` skips user creation (exit 65 = already + exists), rc.d + sysrc overwrite cleanly. +- [ ] (if tested) Linux `--create-pool` works on a spare disk and the empty-disk guard + refuses non-empty disks. +- [ ] Any FreeBSD-specific differences from the Linux-built behavior are filed as a PR + and reported back. ## Platform notes -- 2.45.3 From e268767f791ad7ae3262b099d2c3b0ca83195ff1 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sun, 21 Jun 2026 15:53:43 +0200 Subject: [PATCH 2/3] feat(spawner): add PrivMode::Sudo for hosts with sudo configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses 'sudo -n' to wrap jail commands. Set via COLIBRI_JAIL_PRIV_MODE=sudo. Requires sudoers entry: clawdie ALL=(root) NOPASSWD: /usr/sbin/jexec * The daemon's async spawn closure (edition 2015) may need a follow-up to fully use this mode — the env var and wrapping logic are correct, verified via manual jexec test. --- crates/colibri-daemon/src/spawner.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/colibri-daemon/src/spawner.rs b/crates/colibri-daemon/src/spawner.rs index 4e2e867..74186fe 100644 --- a/crates/colibri-daemon/src/spawner.rs +++ b/crates/colibri-daemon/src/spawner.rs @@ -94,12 +94,14 @@ pub enum PrivMode { Mdo, /// Setuid helper that performs only the jail spawn — deployed hosts. Helper, + /// `sudo -n ` — hosts with sudo configured (no password prompt). + Sudo, /// No escalation: run the jail command directly. None, } impl PrivMode { - /// Resolve from `COLIBRI_JAIL_PRIV_MODE` (`mdo` | `helper` | `none`). + /// Resolve from `COLIBRI_JAIL_PRIV_MODE` (`mdo` | `helper` | `sudo` | `none`). /// Defaults to `Mdo` (the live-USB posture); only consulted when a spawn /// actually requests a jail. pub fn from_env() -> Self { @@ -110,6 +112,7 @@ impl PrivMode { .as_str() { "helper" => PrivMode::Helper, + "sudo" => PrivMode::Sudo, "none" => PrivMode::None, _ => PrivMode::Mdo, } @@ -169,6 +172,11 @@ fn priv_wrap( a.extend(argv); ("mdo".to_string(), a) } + PrivMode::Sudo => { + let mut a = vec!["-n".to_string(), exe]; + a.extend(argv); + ("sudo".to_string(), a) + } PrivMode::Helper => { let mut a = vec![exe]; a.extend(argv); -- 2.45.3 From 13f4ff7cc2ab0770ce879d24ae8e763113bf2cfc Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sun, 21 Jun 2026 16:00:11 +0200 Subject: [PATCH 3/3] fix(spawner): avoid async closure in retry path (Sam & Pi) Move the backoff spawn operation into a named async helper so older tooling does not trip over || async syntax, and add a jail sudo wrapping unit test. Document sudo as an interim validated-host privilege mode.\n\nValidation: ./scripts/check-format.sh; cargo fmt --check; cargo check -p colibri-daemon; cargo test -p colibri-daemon jail_tests -- --nocapture. --- crates/colibri-daemon/src/spawner.rs | 115 +++++++++++++--------- docs/CLAWDIE-INSTALLER-HANDOFF.md | 8 +- docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md | 4 + 3 files changed, 79 insertions(+), 48 deletions(-) diff --git a/crates/colibri-daemon/src/spawner.rs b/crates/colibri-daemon/src/spawner.rs index 74186fe..e64b524 100644 --- a/crates/colibri-daemon/src/spawner.rs +++ b/crates/colibri-daemon/src/spawner.rs @@ -255,6 +255,54 @@ async fn remove_staged_dir(path: &Path) { let _ = tokio::fs::remove_dir_all(path).await; } +async fn spawn_prepared_child( + prepared: &PreparedSpawnCommand, + agent_config: &AgentSpawnConfig, +) -> Result<(Child, Option), SpawnerError> { + let mut cmd = Command::new(&prepared.program); + cmd.args(&prepared.argv) + .envs(&prepared.env) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if agent_config.jail.is_none() { + if let Some(ref dir) = agent_config.working_dir { + cmd.current_dir(dir); + } + } + + let mut child = cmd.spawn().map_err(SpawnerError::Io)?; + + // Brief grace period for the process to start. + tokio::time::sleep(Duration::from_millis(500)).await; + + // Check it has not immediately crashed. + match child.try_wait() { + Ok(Some(status)) => { + let stderr = if let Some(ref mut stderr) = child.stderr.take() { + use tokio::io::AsyncReadExt; + let mut buf = Vec::new(); + let _ = stderr.read_to_end(&mut buf).await; + String::from_utf8_lossy(&buf).to_string() + } else { + String::new() + }; + if let Some(path) = prepared.cleanup_dir.as_deref() { + remove_staged_dir(path).await; + } + Err(SpawnerError::Io(std::io::Error::other(format!( + "process exited immediately with status {status}: {stderr}" + )))) + } + Ok(None) => { + let stdout = child.stdout.take(); + Ok((child, stdout)) + } + Err(e) => Err(SpawnerError::Io(e)), + } +} + #[allow(clippy::too_many_arguments)] pub async fn prepare_spawn_command( binary: &str, @@ -687,50 +735,7 @@ impl Spawner { ); let _startup_timeout = agent_config.startup_timeout_secs; - let op = || async { - let mut cmd = Command::new(&prepared.program); - cmd.args(&prepared.argv) - .envs(&prepared.env) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - if agent_config.jail.is_none() { - if let Some(ref dir) = agent_config.working_dir { - cmd.current_dir(dir); - } - } - - let mut child = cmd.spawn().map_err(SpawnerError::Io)?; - - // Brief grace period for the process to start - tokio::time::sleep(Duration::from_millis(500)).await; - - // Check it hasn't immediately crashed - match child.try_wait() { - Ok(Some(status)) => { - let stderr = if let Some(ref mut stderr) = child.stderr.take() { - use tokio::io::AsyncReadExt; - let mut buf = Vec::new(); - let _ = stderr.read_to_end(&mut buf).await; - String::from_utf8_lossy(&buf).to_string() - } else { - String::new() - }; - if let Some(path) = prepared.cleanup_dir.as_deref() { - remove_staged_dir(path).await; - } - Err(SpawnerError::Io(std::io::Error::other(format!( - "process exited immediately with status {status}: {stderr}" - )))) - } - Ok(None) => { - let stdout = child.stdout.take(); - Ok((child, stdout)) - } - Err(e) => Err(SpawnerError::Io(e)), - } - }; + let op = || spawn_prepared_child(&prepared, &agent_config); let backoff = ExponentialBuilder::default() .with_min_delay(Duration::from_millis(500)) @@ -828,6 +833,28 @@ mod jail_tests { assert_eq!(a, argv(&["-u", "root", "jexec", "pi0", "pi", "-p", "task"])); } + #[test] + fn named_jail_via_sudo() { + let j = JailConfig { + name: Some("proof0".into()), + root_path: Some("/usr/local/bastille/jails/proof0/root".into()), + user: Some("clawdie".into()), + ..Default::default() + }; + let (exe, a) = jail_wrap( + "pi", + &argv(&["-p", "task"]), + Some(&j), + &PrivMode::Sudo, + DEFAULT_JAIL_HELPER, + ); + assert_eq!(exe, "sudo"); + assert_eq!( + a, + argv(&["-n", "jexec", "-U", "clawdie", "proof0", "pi", "-p", "task"]) + ); + } + #[test] fn named_jail_with_user_no_priv() { let j = JailConfig { diff --git a/docs/CLAWDIE-INSTALLER-HANDOFF.md b/docs/CLAWDIE-INSTALLER-HANDOFF.md index e1315ea..5c5580c 100644 --- a/docs/CLAWDIE-INSTALLER-HANDOFF.md +++ b/docs/CLAWDIE-INSTALLER-HANDOFF.md @@ -107,13 +107,13 @@ Not done: no destructive `apply --yes`; still requires scratch pool/VM. - [x] `cargo test -p clawdie` passes on FreeBSD 15 (output + versions reported). - [x] `discover` + `plan` correct against a real FreeBSD ZFS host for read-only/dry-run paths. - [x] `apply --yes` on a scratch pool creates the datasets, user, and rc.d service as - specified; teardown verified. (OSA file-backed testpool, 2026-06-21.) + specified; teardown verified. (OSA file-backed testpool, 2026-06-21.) - [x] Idempotent re-run: second `apply --yes` skips user creation (exit 65 = already - exists), rc.d + sysrc overwrite cleanly. + exists), rc.d + sysrc overwrite cleanly. - [ ] (if tested) Linux `--create-pool` works on a spare disk and the empty-disk guard - refuses non-empty disks. + refuses non-empty disks. - [ ] Any FreeBSD-specific differences from the Linux-built behavior are filed as a PR - and reported back. + and reported back. ## Platform notes diff --git a/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md b/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md index 1c8b15b..0002f37 100644 --- a/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md +++ b/docs/COLIBRI-JAILED-AGENT-SPAWN-DESIGN.md @@ -54,6 +54,10 @@ just `jexec`. We choose the escalation per host via `PrivMode` root is a real escalation surface, so use a narrow setuid helper (`/usr/local/libexec/colibri-jail-spawn`) that only performs the jail spawn, and keep the daemon unprivileged. +- **Validated hosts with existing sudo policy → `sudo`.** `sudo -n` can be used + as an interim proof/ops mode when a narrow sudoers rule already permits the + daemon user to run the jail command without prompting. Prefer the setuid helper + for long-lived production hosts once packaged. - **`none`** — run the jail command directly (already root, or tests). ## Staged env payloads -- 2.45.3