From 024e477230e824c877d92e5a7576ee0341a164e8 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 00:26:06 +0900 Subject: [PATCH 1/6] runner: give each pipe child one reaper (CSRG-U5) A pipe child could be reaped by any of four paths sharing its tokio Child: its reaper task, the timeout task and request_kill (both called try_wait before killing), and tokio's orphan queue once the child was dropped. Whichever ran first reaped, so no path could observe the exit before the reap, as DevGuard needs for a governed scope. reap_child is now the only path that waits. The timeout and termination paths record their intent and kill only a child that has not exited, which they see with waitid(WEXITED | WNOHANG | WNOWAIT) without reaping it; an exited child is left to the reaper, which records how it ended. Until the reaper waits, the PID is still this process's child, so a kill never reaches another process. kill_on_drop stays as the hand-off at shutdown, once the reaper task is gone. Deterministic tests hold the reaper while the child is an exited, unreaped zombie and check that termination and the timeout neither reap it nor change its result, and that it is reaped exactly once. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/runner/src/process.rs | 227 +++++++++++++++++++++++++++++------ 1 file changed, 187 insertions(+), 40 deletions(-) diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index 8a57ed9..d47a36d 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -149,6 +149,52 @@ pub(crate) mod inheritable_window { } } +/// Lets a test hold a pipe child's reaper, so that an exited child stays unreaped while other +/// paths act on it, and count the reaps. +#[cfg(test)] +pub(crate) mod reaper_gate { + use std::sync::Mutex; + + static HELD: Mutex> = Mutex::new(Vec::new()); + static REAPS: Mutex> = Mutex::new(Vec::new()); + + /// Hold `process_id`'s reaper until [`release`]. + pub(crate) fn hold(process_id: &str) { + HELD.lock().unwrap().push(process_id.to_owned()); + } + + pub(crate) fn release(process_id: &str) { + HELD.lock().unwrap().retain(|id| id != process_id); + } + + /// How many times `process_id`'s child was reaped. + pub(crate) fn reaps(process_id: &str) -> usize { + REAPS + .lock() + .unwrap() + .iter() + .filter(|id| *id == process_id) + .count() + } + + pub(super) fn held(process_id: &str) -> bool { + HELD.lock().unwrap().iter().any(|id| id == process_id) + } + + pub(super) fn reaped(process_id: &str) { + REAPS.lock().unwrap().push(process_id.to_owned()); + } +} + +#[cfg(not(test))] +mod reaper_gate { + pub(super) fn held(_process_id: &str) -> bool { + false + } + + pub(super) fn reaped(_process_id: &str) {} +} + /// What a spawn starts: the request's own command, or a governed execution's launch helper. enum How { Direct, @@ -601,7 +647,7 @@ impl InProcessRunner { let wait_release = self.on_release.clone(); let wait_process = process_id; tokio::spawn(async move { - reap_child(wait_child, wait_life).await; + reap_child(wait_child, wait_life, &wait_process).await; join_pump(out_handle).await; join_pump(err_handle).await; if let Ok(mut buf) = wait_out.lock() { @@ -620,27 +666,16 @@ impl InProcessRunner { tokio::time::sleep(timeout).await; let mut ch = timeout_child.lock().expect("child"); let mut life = timeout_life.lock().expect("lifecycle"); - if life.finished { + // Only the reaper waits: a child that has exited ended on its own, and the reaper + // records how; one that has not is still unreaped, so the kill reaches it alone. + if life.finished || exited_unreaped(&ch) { return; } - match ch.try_wait() { - Ok(Some(status)) => life.finish_wait(status.code(), status.signal()), - Ok(None) => { - life.note_kill(KillIntent::Timeout); - let _ = ch.start_kill(); - drop(life); - if let Ok(mut buf) = timeout_out.lock() { - buf.timed_out = true; - } - } - Err(_) => { - life.note_kill(KillIntent::Timeout); - life.finish_lost(); - drop(life); - if let Ok(mut buf) = timeout_out.lock() { - buf.timed_out = true; - } - } + life.note_kill(KillIntent::Timeout); + let _ = ch.start_kill(); + drop(life); + if let Ok(mut buf) = timeout_out.lock() { + buf.timed_out = true; } }); @@ -979,9 +1014,12 @@ impl InProcessRunner { } } -async fn reap_child(child: Arc>, lifecycle: Arc>) { +/// A pipe child's one reaper (CSRG-U5): no other path waits for it. Timeout and termination +/// record their intent and signal only a child this has not reaped yet, and a dropped child is +/// left to tokio's orphan queue only once this task is gone (shutdown). +async fn reap_child(child: Arc>, lifecycle: Arc>, process_id: &str) { loop { - { + if !reaper_gate::held(process_id) { let mut ch = child.lock().expect("child"); let mut life = lifecycle.lock().expect("lifecycle"); if life.finished { @@ -989,6 +1027,7 @@ async fn reap_child(child: Arc>, lifecycle: Arc>) } match ch.try_wait() { Ok(Some(status)) => { + reaper_gate::reaped(process_id); life.finish_wait(status.code(), status.signal()); return; } @@ -1003,6 +1042,34 @@ async fn reap_child(child: Arc>, lifecycle: Arc>) } } +/// Whether `child` has exited, seen without reaping it. Until its reaper waits, an exited child +/// still holds its PID, so no other process can be reached through that PID. +fn exited_unreaped(child: &Child) -> bool { + let Some(pid) = child.id() else { + // tokio has seen its exit. + return true; + }; + // SAFETY: an all-zero `siginfo_t` is valid, and zero `si_pid` is how `waitid` with + // `WNOHANG` reports that no child has exited. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is a valid `siginfo_t`; `WNOWAIT` leaves the child waitable for its + // reaper and `WNOHANG` returns at once. + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result == -1 { + // Not this process's child any more: never signal that PID. + return std::io::Error::last_os_error().raw_os_error() == Some(libc::ECHILD); + } + // SAFETY: `waitid` filled `info`, or left the zero it was given. + unsafe { info.si_pid() != 0 } +} + async fn join_pump(handle: Option>) { if let Some(handle) = handle { let _ = handle.await; @@ -1014,26 +1081,15 @@ fn request_kill(slot: &Slot) -> Result { SessionIo::Pipe { child, .. } => { let mut child = child.lock().expect("child"); let mut life = slot.lifecycle.lock().expect("lifecycle"); - if life.finished { + // Only the reaper waits (see `reap_child`): an exited child is left to it. + if life.finished || exited_unreaped(&child) { return Ok(false); } - match child.try_wait() { - Ok(Some(status)) => { - life.finish_wait(status.code(), status.signal()); - Ok(false) - } - Ok(None) => { - life.note_kill(KillIntent::Terminated); - child - .start_kill() - .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; - Ok(true) - } - Err(_) => { - life.finish_lost(); - Ok(false) - } - } + life.note_kill(KillIntent::Terminated); + child + .start_kill() + .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; + Ok(true) } SessionIo::Pty { session, .. } => { let mut life = slot.lifecycle.lock().expect("lifecycle"); @@ -2044,6 +2100,97 @@ mod tests { ); } + /// Wait until `process_id`'s pipe child has exited, while its reaper is held. + async fn exited_but_unreaped(runner: &InProcessRunner, process_id: &ProcessId) { + for _ in 0..250 { + let exited = { + let map = runner.inner.lock().unwrap(); + match &map[&process_id.0].io { + SessionIo::Pipe { child, .. } => exited_unreaped(&child.lock().unwrap()), + SessionIo::Pty { .. } => panic!("{} is not a pipe child", process_id.0), + } + }; + if exited { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("{} did not exit", process_id.0); + } + + fn held_request(argv: &[&str], process_id: &ProcessId, timeout_ms: u64) -> RunnerExecRequest { + reaper_gate::hold(&process_id.0); + let mut req = RunnerExecRequest::for_host( + argv.iter().map(|arg| arg.to_string()).collect(), + process_id.clone(), + Profile::WorkspaceWrite, + ); + req.timeout_ms = timeout_ms; + req + } + + #[tokio::test] + async fn an_exited_child_keeps_its_one_reaper_whatever_else_acts_on_it() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-one-reaper-exit".into()); + let req = held_request(&["/bin/sh", "-c", "exit 3"], &process_id, 300); + runner.exec(&ws, req).await.unwrap(); + exited_but_unreaped(&runner, &process_id).await; + // Termination and the timeout come while the exited child is still unreaped: neither + // waits for it, kills it or changes how it ended. + runner.terminate(&process_id).await.unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!(reaper_gate::reaps(&process_id.0), 0); + let status = runner.process_status(&process_id).await.unwrap(); + assert_eq!(status.state, ProcessState::Running); + reaper_gate::release(&process_id.0); + let status = wait_exited(&runner, &process_id).await; + assert_eq!( + (status.exit_code, status.termination), + (Some(3), Some(ProcessTermination::Exited)) + ); + assert_eq!(reaper_gate::reaps(&process_id.0), 1); + } + + #[tokio::test] + async fn a_terminated_child_keeps_its_one_reaper() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-one-reaper-terminated".into()); + let req = held_request(&["/bin/sleep", "30"], &process_id, 30_000); + runner.exec(&ws, req).await.unwrap(); + runner.terminate(&process_id).await.unwrap(); + exited_but_unreaped(&runner, &process_id).await; + // A second termination finds it exited and leaves it to the reaper. + runner.terminate(&process_id).await.unwrap(); + assert_eq!(reaper_gate::reaps(&process_id.0), 0); + reaper_gate::release(&process_id.0); + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.termination, Some(ProcessTermination::Terminated)); + assert_eq!(reaper_gate::reaps(&process_id.0), 1); + } + + #[tokio::test] + async fn a_timed_out_child_keeps_its_one_reaper() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-one-reaper-timeout".into()); + let req = held_request(&["/bin/sleep", "30"], &process_id, 100); + runner.exec(&ws, req).await.unwrap(); + exited_but_unreaped(&runner, &process_id).await; + // Termination after the timeout's kill finds it exited and leaves it to the reaper. + runner.terminate(&process_id).await.unwrap(); + assert_eq!(reaper_gate::reaps(&process_id.0), 0); + reaper_gate::release(&process_id.0); + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.termination, Some(ProcessTermination::Timeout)); + assert_eq!(reaper_gate::reaps(&process_id.0), 1); + } + #[tokio::test] async fn terminate_status_has_no_exit_code() { let dir = tempdir().unwrap(); From 9c5724562e4beea1aa15951980fbfc08d54cd6b2 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 00:38:22 +0900 Subject: [PATCH 2/6] devguard, runner, server: observe a governed scope before its root is reaped (CSRG-U5) DevGuard tracks what is left in a scope's group only while the exited root still holds its PID: a scope observed only after its root was reaped loses track of survivors, and the attempt stays charged as Suspect. DevGuard's own CLI waits for the root without reaping it, observes, reaps and observes again. The runner gets a generic exit observer: a pipe child's one reaper sees the exit without reaping (waitid with WNOWAIT), calls the observer while the root is an unreaped zombie, reaps it, and calls it again. The observer runs on a blocking thread for at most two seconds; when it fails or runs longer the reaper reaps anyway, so it never adds a reaper. A PTY child is reaped inside the pinned Codex PTY, whose API has no hook before the reap, so its observer runs after the reap only: a descendant DevGuard has not yet seen when the root exits is then lost to tracking, and its attempt stays charged rather than released. A managed launch's observer asks DevGuard to Observe the attempt (Owner::observe, a bounded session like lookup); the answer changes nothing CodeSpace does with the process. Tests: the runner's observer is called with the root exited and unreaped, then reaped; a slow observer does not keep the child unreaped; a PTY's observer is called after the reap only; a managed launch observes twice on a pipe and once on a PTY. Against DevGuard's fixture authority and helper (macOS): observed before the reap, a surviving descendant keeps the attempt charged and is released as ScopeTerminated once it ends; reaped first, the attempt turns Suspect with lost tracking and stays charged; and through the gateway, a governed pipe execution whose root leaves a descendant stays charged until the descendant ends, then is released. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/devguard/src/admission.rs | 13 +- crates/devguard/src/launch.rs | 126 ++++++++++++++++++ crates/runner/src/admission.rs | 14 +- crates/runner/src/launch.rs | 23 +++- crates/runner/src/process.rs | 215 ++++++++++++++++++++++++++++--- crates/server/src/mcp.rs | 52 ++++++++ 6 files changed, 422 insertions(+), 21 deletions(-) diff --git a/crates/devguard/src/admission.rs b/crates/devguard/src/admission.rs index 34b8f10..2248b5e 100644 --- a/crates/devguard/src/admission.rs +++ b/crates/devguard/src/admission.rs @@ -259,6 +259,15 @@ impl Owner { ) } + /// Ask DevGuard to observe the attempt's scope now and settle it by its usual rules + /// (CSRG-U5). Sent while the scope's exited root is still unreaped, it lets DevGuard keep + /// tracking the members left in the root's group; sent after the reap, it settles what + /// DevGuard can prove. It never releases a scope that cannot be proven ended. Answers the + /// attempt's record. + pub fn observe(&self, admission: &Admission) -> Answer { + self.attempt_request(admission, Request::Observe, effective_uid(), self.pid()) + } + pub(crate) fn admit_with(&self, admission: &Admission, uid: u32, pid: u32) -> Answer { self.attempt_request(admission, Request::Admit, uid, pid) } @@ -289,7 +298,7 @@ impl Owner { return Answer::Refused(ErrorCode::InvalidRequest); }; let compatibility = match request { - Request::Abandon(_) => crate::launch::launch_compatibility(), + Request::Abandon(_) | Request::Observe => crate::launch::launch_compatibility(), Request::Admit | Request::Lookup | Request::Cancel => admission_compatibility(), }; let (mut client, _) = match self.open_with(authority_uid, compatibility, owner_pid) { @@ -303,6 +312,7 @@ impl Owner { Request::Lookup => client.lookup(key.clone()), Request::Cancel => client.cancel(key.clone()), Request::Abandon(reason) => client.abandon_launch(key.clone(), reason.wire()), + Request::Observe => client.observe(key.clone()), }; match answered { Ok(record) => self.attempt(&record, &key, &fingerprint), @@ -394,6 +404,7 @@ enum Request { Lookup, Cancel, Abandon(Abandon), + Observe, } /// An error answer is definite, except DevGuard's client's own code for a transport failure. diff --git a/crates/devguard/src/launch.rs b/crates/devguard/src/launch.rs index 3f037d8..fc13f02 100644 --- a/crates/devguard/src/launch.rs +++ b/crates/devguard/src/launch.rs @@ -985,6 +985,132 @@ pub(crate) mod tests { assert_eq!(charged(&provisioned), Budget::ZERO); } + /// Wait until the child `pid` has exited, without reaping it. + fn exited_unreaped(pid: u32) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + // SAFETY: an all-zero `siginfo_t` is valid; `WNOWAIT` leaves the child waitable. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is a valid `siginfo_t` for `waitid` to fill. + let result = unsafe { + libc::waitid( + libc::P_PID, + pid, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + assert_eq!(result, 0, "{}", std::io::Error::last_os_error()); + // SAFETY: `waitid` filled `info`, or left the zero it was given. + if unsafe { info.si_pid() } != 0 { + return; + } + assert!(Instant::now() < deadline, "{pid} did not exit"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn observed(owner: &Owner, admission: &Admission) -> crate::admission::Attempt { + match owner.observe(admission) { + crate::admission::Answer::Attempt(attempt) => attempt, + other => panic!("not observed: {other:?}"), + } + } + + /// A started helper whose executable exits at once, leaving a descendant in its group + /// for two seconds. DevGuard's background reconciler is paused, so only this owner's + /// observations settle the scope. + fn started_with_a_survivor( + provisioned: &Provisioned, + owner: &Owner, + id: &str, + ) -> Option<(Admission, Child)> { + let helper = helper_bin()?; + provisioned.authority.pause_reconciler(true); + let script = "sleep 2 /dev/null 2>&1 & exit 0"; + let admission = admission(id, &["/bin/sh", "-c", script]); + admitted(owner, &admission); + let permit = granted(owner, &admission); + let invocation = owner + .helper( + &helper, + &admission, + permit, + Path::new("/bin/sh"), + &["-c".into(), script.into()], + ) + .unwrap(); + let child = spawn(&invocation, Stdio::null()); + assert_eq!(invocation.into_transcript().read(), TranscriptEnd::Started); + exited_unreaped(child.id()); + Some((admission, child)) + } + + #[test] + fn an_observation_before_the_reap_keeps_a_surviving_descendant_tracked() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let Some((admission, mut child)) = + started_with_a_survivor(&provisioned, &owner, "cs-observe-1") + else { + return; + }; + // While the exited root still holds its PID, DevGuard adopts what is left in its + // group, and keeps tracking it after the reap. + let before = observed(&owner, &admission); + assert!( + before.phase != Phase::Released && !before.tracking_lost, + "{before:?}" + ); + child.wait().unwrap(); + let after = observed(&owner, &admission); + assert!( + after.phase != Phase::Released && !after.tracking_lost, + "{after:?}" + ); + assert_ne!(charged(&provisioned), Budget::ZERO); + // Once the descendant has ended, an observation releases the scope. + let deadline = Instant::now() + Duration::from_secs(10); + let ended = loop { + let attempt = observed(&owner, &admission); + if attempt.phase == Phase::Released || Instant::now() > deadline { + break attempt; + } + std::thread::sleep(Duration::from_millis(200)); + }; + assert_eq!( + (ended.phase, ended.release, ended.tracking_lost), + (Phase::Released, Some(Release::ScopeTerminated), false) + ); + assert_eq!(charged(&provisioned), Budget::ZERO); + } + + #[test] + fn a_reap_before_any_observation_loses_a_surviving_descendant() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let Some((admission, mut child)) = + started_with_a_survivor(&provisioned, &owner, "cs-observe-2") + else { + return; + }; + // Reaped first: what is left in the group can no longer be proven the scope's. + child.wait().unwrap(); + let after = observed(&owner, &admission); + assert!( + after.tracking_lost && after.phase != Phase::Released, + "{after:?}" + ); + // It stays charged even once the descendant has ended. + std::thread::sleep(Duration::from_secs(3)); + let later = observed(&owner, &admission); + assert!( + later.tracking_lost && later.phase != Phase::Released, + "{later:?}" + ); + assert_ne!(charged(&provisioned), Budget::ZERO); + } + #[test] fn a_lost_grant_is_found_committed_and_released_as_never_received() { let provisioned = admitting(); diff --git a/crates/runner/src/admission.rs b/crates/runner/src/admission.rs index d54b7d2..7017c0f 100644 --- a/crates/runner/src/admission.rs +++ b/crates/runner/src/admission.rs @@ -62,6 +62,9 @@ pub trait AttemptAuthority: Send + Sync + 'static { fn begin_launch(&self, admission: &Admission) -> LaunchAnswer; /// Report that this owner holds no helper for the attempt's grant (CSRG-U4). fn abandon_launch(&self, admission: &Admission, reason: Abandon) -> Answer; + /// Have DevGuard observe the attempt's scope now (CSRG-U5): while its exited root is still + /// unreaped, so that members left in its group stay tracked, and again after the reap. + fn observe(&self, admission: &Admission) -> Answer; /// The launch helper's invocation for the attempt's grant (CSRG-U4). fn helper( &self, @@ -95,6 +98,9 @@ impl AttemptAuthority for devguard::Owner { fn abandon_launch(&self, admission: &Admission, reason: Abandon) -> Answer { devguard::Owner::abandon_launch(self, admission, reason) } + fn observe(&self, admission: &Admission) -> Answer { + devguard::Owner::observe(self, admission) + } fn helper( &self, helper: &Path, @@ -1088,7 +1094,7 @@ pub(crate) mod tests { grants: Mutex>, abandons: Mutex>, admit_delay: Mutex, - calls: [AtomicUsize; 5], + calls: [AtomicUsize; 6], seen: Mutex>, pub(crate) abandoned_for: Mutex>, } @@ -1132,6 +1138,7 @@ pub(crate) mod tests { pub(crate) const CANCEL: usize = 2; pub(crate) const BEGIN: usize = 3; pub(crate) const ABANDON: usize = 4; + pub(crate) const OBSERVE: usize = 5; /// A permit as a test authority would grant one. pub(crate) const PERMIT: &str = @@ -1191,6 +1198,11 @@ pub(crate) mod tests { )), ) } + fn observe(&self, admission: &Admission) -> Answer { + self.calls[OBSERVE].fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(admission.clone()); + Answer::Unknown + } fn helper( &self, helper: &Path, diff --git a/crates/runner/src/launch.rs b/crates/runner/src/launch.rs index c5d9b56..dd8e713 100644 --- a/crates/runner/src/launch.rs +++ b/crates/runner/src/launch.rs @@ -369,9 +369,18 @@ impl PreparedExecution { let attempt = self.abandon(Abandon::SpawnFailed).await; return self.not_launched(PreparationOutcome::HelperNotCreated, attempt); }; + // DevGuard observes the scope while the exited root still holds its PID, so members left + // in its group stay tracked, and again after the reap (CSRG-U5). Its answer changes + // nothing CodeSpace does with the process. + let observer: crate::process::ExitObserver = { + let (authority, admission) = (self.authority.clone(), self.admission.clone()); + Arc::new(move |_| { + let _ = authority.observe(&admission); + }) + }; let (spawned, transcript) = self .runner - .spawn_managed(&self.ws, request.clone(), slot, invocation) + .spawn_managed(&self.ws, request.clone(), slot, invocation, observer) .await; if spawned.is_err() { // No helper exists, and none will be created for this grant. @@ -663,7 +672,7 @@ mod tests { use super::*; use crate::admission::tests::{ attempt, governed, prepared, record, request, runner, Fake, Grant, ABANDON, ADMIT, BEGIN, - CANCEL, LOOKUP, + CANCEL, LOOKUP, OBSERVE, }; use crate::admission::Preparer; use crate::{InProcessRunner, Runner, RunnerReadProcess}; @@ -882,6 +891,16 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} (1, 1, 1, 0, 0) ); assert_eq!(settled_slots(&runner).await, 0); + // DevGuard is asked to observe the scope before the reap and after it on a pipe; + // the pinned Codex PTY reaps first, so after it only (CSRG-U5). + let observes = if tty { 1 } else { 2 }; + for _ in 0..250 { + if fake.count(OBSERVE) >= observes { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!(fake.count(OBSERVE), observes, "tty={tty}"); } } diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index d47a36d..59e18b7 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -275,6 +275,35 @@ impl Managed { } } +/// When a process's reaper calls its exit observer (CSRG-U5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitPoint { + /// The root has exited and is not reaped yet, so it still holds its PID. + BeforeReap, + /// The root has been reaped. + AfterReap, +} + +/// A hook that a process's one reaper calls while the exited root is still unreaped, and again +/// once it has reaped it (CSRG-U5): for example to have a resource authority observe the +/// process's scope while its group ID still provably names it. It runs on a blocking thread +/// for at most [`OBSERVE_BOUND`]; when it fails or runs longer the reaper reaps anyway, and +/// nothing else ever waits for the child. A PTY child is reaped inside the pinned Codex PTY, +/// so its observer is called after the reap only. +pub type ExitObserver = Arc; + +/// How long a reaper waits for its exit observer. +pub const OBSERVE_BOUND: Duration = Duration::from_secs(2); + +/// Call `observer` at `point`, for at most [`OBSERVE_BOUND`]. +async fn observe(observer: &Option, point: ExitPoint) { + let Some(observer) = observer.clone() else { + return; + }; + let call = tokio::task::spawn_blocking(move || observer(point)); + let _ = tokio::time::timeout(OBSERVE_BOUND, call).await; +} + struct SpawnCtx { cwd: PathBuf, cap: usize, @@ -283,6 +312,7 @@ struct SpawnCtx { completed_at: Arc>>, lifecycle: Arc>, process_id: String, + observer: Option, } #[derive(Default)] @@ -496,14 +526,15 @@ impl InProcessRunner { } // The slot first: no process is created without one. let slot = self.reserve_slot(&req.process_id.0)?; - self.spawn_slotted(ws, req, slot, How::Direct).await + self.spawn_slotted(ws, req, slot, How::Direct, None).await } /// Start a governed execution's launch helper through this runner's own pipe or PTY spawner /// (CSRG-U4), filling the slot its preparation took. The helper alone receives the /// invocation's two descriptors, and this process's copies are closed as soon as the spawn /// returns. The helper becomes the execution: from here it is a process like any other. - /// Returns the spawn's result and the owner's end of the helper's transcript. + /// Its reaper calls `observer` before and after the reap (CSRG-U5). Returns the spawn's + /// result and the owner's end of the helper's transcript. #[cfg(feature = "devguard")] pub(crate) async fn spawn_managed( &self, @@ -511,13 +542,14 @@ impl InProcessRunner { req: RunnerExecRequest, slot: SlotReservation, invocation: codespace_devguard::launch::HelperInvocation, + observer: ExitObserver, ) -> ( Result, codespace_devguard::launch::Transcript, ) { let managed = Arc::new(Managed::new(invocation)); let result = self - .spawn_slotted(ws, req, slot, How::Managed(managed.clone())) + .spawn_slotted(ws, req, slot, How::Managed(managed.clone()), Some(observer)) .await; (result, managed.finish()) } @@ -528,6 +560,7 @@ impl InProcessRunner { req: RunnerExecRequest, slot: SlotReservation, how: How, + observer: Option, ) -> Result { let cap = req.output_bytes_cap.max(1) as usize; let timeout = Duration::from_millis(req.timeout_ms.max(1)); @@ -542,6 +575,7 @@ impl InProcessRunner { completed_at: Arc::new(Mutex::new(None)), lifecycle: Arc::new(Mutex::new(Lifecycle::new())), process_id: req.process_id.0.clone(), + observer, }; if req.tty { self.spawn_pty(ws, req, ctx, slot, how).await @@ -566,6 +600,7 @@ impl InProcessRunner { completed_at, lifecycle, process_id, + observer, } = ctx; let (mut child, launch) = match &how { How::Direct => { @@ -647,7 +682,7 @@ impl InProcessRunner { let wait_release = self.on_release.clone(); let wait_process = process_id; tokio::spawn(async move { - reap_child(wait_child, wait_life, &wait_process).await; + reap_child(wait_child, wait_life, &wait_process, observer).await; join_pump(out_handle).await; join_pump(err_handle).await; if let Ok(mut buf) = wait_out.lock() { @@ -700,6 +735,7 @@ impl InProcessRunner { completed_at, lifecycle, process_id, + observer, } = ctx; let launch = match &how { How::Direct => Some(exec_launch(ws, &req)?), @@ -811,6 +847,8 @@ impl InProcessRunner { .finish_wait(Some(code), None), Err(_) => wait_life.lock().expect("lifecycle").finish_lost(), } + // The pinned Codex PTY has reaped the root already: observed after the reap only. + observe(&observer, ExitPoint::AfterReap).await; join_pump(out_handle).await; if let Ok(mut buf) = wait_out.lock() { buf.eof = true; @@ -1017,23 +1055,43 @@ impl InProcessRunner { /// A pipe child's one reaper (CSRG-U5): no other path waits for it. Timeout and termination /// record their intent and signal only a child this has not reaped yet, and a dropped child is /// left to tokio's orphan queue only once this task is gone (shutdown). -async fn reap_child(child: Arc>, lifecycle: Arc>, process_id: &str) { +async fn reap_child( + child: Arc>, + lifecycle: Arc>, + process_id: &str, + observer: Option, +) { loop { if !reaper_gate::held(process_id) { - let mut ch = child.lock().expect("child"); - let mut life = lifecycle.lock().expect("lifecycle"); - if life.finished { - return; - } - match ch.try_wait() { - Ok(Some(status)) => { - reaper_gate::reaped(process_id); - life.finish_wait(status.code(), status.signal()); + let exited = { + let ch = child.lock().expect("child"); + if lifecycle.lock().expect("lifecycle").finished { return; } - Ok(None) => {} - Err(_) => { - life.finish_lost(); + exited_unreaped(&ch) + }; + if exited { + // The exited root still holds its PID: observed now, then reaped, then + // observed again. + observe(&observer, ExitPoint::BeforeReap).await; + let reaped = { + let mut ch = child.lock().expect("child"); + let mut life = lifecycle.lock().expect("lifecycle"); + match ch.try_wait() { + Ok(Some(status)) => { + reaper_gate::reaped(process_id); + life.finish_wait(status.code(), status.signal()); + true + } + Ok(None) => false, + Err(_) => { + life.finish_lost(); + true + } + } + }; + if reaped { + observe(&observer, ExitPoint::AfterReap).await; return; } } @@ -2129,6 +2187,129 @@ mod tests { req } + /// Spawn `argv` with `observer`, as a governed execution's spawn does. + async fn spawn_observed( + runner: &InProcessRunner, + ws: &Workspace, + process_id: &ProcessId, + argv: &[&str], + tty: bool, + observer: ExitObserver, + ) { + let mut req = RunnerExecRequest::for_host( + argv.iter().map(|arg| arg.to_string()).collect(), + process_id.clone(), + Profile::WorkspaceWrite, + ); + req.tty = tty; + let slot = runner.reserve_slot(&process_id.0).unwrap(); + runner + .spawn_slotted(ws, req, slot, How::Direct, Some(observer)) + .await + .unwrap(); + } + + #[tokio::test] + async fn the_observer_sees_the_exited_root_unreaped_then_reaped() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-observed-pipe".into()); + let seen = Arc::new(Mutex::new(Vec::new())); + let observer: ExitObserver = { + let (runner, process_id, seen) = (runner.clone(), process_id.clone(), seen.clone()); + Arc::new(move |point| { + let unreaped = { + let map = runner.inner.lock().unwrap(); + match &map[&process_id.0].io { + SessionIo::Pipe { child, .. } => { + let child = child.lock().unwrap(); + child.id().is_some() && exited_unreaped(&child) + } + SessionIo::Pty { .. } => panic!("not a pipe child"), + } + }; + seen.lock().unwrap().push((point, unreaped)); + }) + }; + spawn_observed( + &runner, + &ws, + &process_id, + &["/bin/sh", "-c", "exit 4"], + false, + observer, + ) + .await; + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.exit_code, Some(4)); + for _ in 0..250 { + if seen.lock().unwrap().len() == 2 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!( + *seen.lock().unwrap(), + [(ExitPoint::BeforeReap, true), (ExitPoint::AfterReap, false)] + ); + } + + #[tokio::test] + async fn an_observer_that_does_not_return_does_not_keep_the_child_unreaped() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-observed-slow".into()); + let observer: ExitObserver = Arc::new(|_| std::thread::sleep(Duration::from_secs(3))); + let started = Instant::now(); + spawn_observed( + &runner, + &ws, + &process_id, + &["/bin/sh", "-c", "exit 5"], + false, + observer, + ) + .await; + let status = wait_exited(&runner, &process_id).await; + // The reaper waited at most its bound for the observer, then reaped, once. + assert!(started.elapsed() < OBSERVE_BOUND + Duration::from_millis(900)); + assert_eq!(status.exit_code, Some(5)); + assert_eq!(reaper_gate::reaps(&process_id.0), 1); + } + + #[tokio::test] + async fn a_pty_childs_observer_is_called_after_the_reap_only() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-observed-pty".into()); + let seen = Arc::new(Mutex::new(Vec::new())); + let observer: ExitObserver = { + let seen = seen.clone(); + Arc::new(move |point| seen.lock().unwrap().push(point)) + }; + spawn_observed( + &runner, + &ws, + &process_id, + &["/bin/sh", "-c", "exit 0"], + true, + observer, + ) + .await; + wait_exited(&runner, &process_id).await; + for _ in 0..250 { + if !seen.lock().unwrap().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + // The pinned Codex PTY reaps inside its own task. + assert_eq!(*seen.lock().unwrap(), [ExitPoint::AfterReap]); + } + #[tokio::test] async fn an_exited_child_keeps_its_one_reaper_whatever_else_acts_on_it() { let dir = tempdir().unwrap(); diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 69e559e..bd82aa6 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -3411,6 +3411,58 @@ mod devguard_tests { assert_eq!(released(&mut fixture), (Vec::new(), 0)); } + /// A governed pipe execution whose root exits leaving a descendant (CSRG-U5): CodeSpace's + /// one reaper has DevGuard observe the scope while the exited root still holds its PID, so + /// the descendant keeps the attempt charged, and DevGuard releases it once the descendant + /// ends. Reaped before any observation, the attempt would have stayed charged for good. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_surviving_descendant_keeps_a_governed_attempt_charged_until_it_ends() { + let Some(mut fixture) = FixtureAuthority::start(2) else { + return; + }; + let Some(helper) = launch_helper() else { + return; + }; + if !fixture.native { + return; + } + let dir = tempfile::tempdir().unwrap(); + let cli = register_cli( + &fixture.settings, + &[ + "--runner", + "in-process", + "--devguard-launch-helper", + helper.to_str().unwrap(), + ], + ); + let store = Arc::new(Store::memory().unwrap()); + let handler = CodeSpace::from_cli( + &cli, + launch_registry(dir.path()), + store.clone(), + RuntimeBackend::in_process(release(&store)), + ); + let script = "sleep 3 /dev/null 2>&1 & echo started"; + let result = executed( + &handler, + exec_params("gov", &["/bin/sh", "-c", script], false), + ) + .await + .unwrap(); + let (output, status) = finished(&handler, &result.process_id).await; + assert_eq!((output.as_str(), status.exit_code), ("started\n", Some(0))); + // The root has exited and been reaped; its descendant still runs in the scope. + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + assert_eq!( + fixture.charged().0.len(), + 1, + "the descendant keeps the attempt charged" + ); + // Once it has ended, DevGuard releases the attempt. + assert_eq!(released(&mut fixture), (Vec::new(), 0)); + } + /// D6 for the launch carriers: governed launches run concurrently with ordinary pipe and PTY /// children of the same gateway. No child but each launch's own helper may hold its permit /// carrier or transcript; no governed command holds anything beyond its standard From efd5070ae612ae5d0bdb87cb692193371d26fcd0 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 00:49:58 +0900 Subject: [PATCH 3/6] devguard, runner, server: kill a governed scope with its root; keep control without the authority (CSRG-U5) CodeSpace's termination and timeout kill a pipe child's root only, so a governed execution's descendants outlived them and kept its attempt charged. A kill hook now joins the exit observer in the spawn's hooks: when CodeSpace kills a process it calls the hook on its own blocking thread, never awaited, and a managed launch's hook asks DevGuard to Terminate the scope (Owner::terminate: SIGKILL to every identity it can recheck, members and known escapes). CodeSpace's own kill never depends on it, and delivery is not release evidence. An authority that stops answering after the launch leaves the process under CodeSpace's control: status comes from CodeSpace's own state, termination and the timeout kill it at once, and its reaper waits at most its bound for an observation before reaping. Tests: the kill hook runs once per kill CodeSpace makes, on a pipe and a PTY, and not for a process that ended on its own; a governed timeout asks DevGuard to terminate the scope once; with the authority stalled, termination and the timeout still end a governed process within the observation bound; against the fixture authority and helper (macOS), Terminate kills both the root and a background descendant and the scope is released as ScopeTerminated; through the gateway, with the fixture stopped, governed pipe and PTY processes report their status, are terminated, and free their workspaces. A regression test pins that a request cancelled after the launch's commit does not drop the launch's settlement (already so: the launch runs in its own task). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/devguard/src/admission.rs | 34 +++++ crates/devguard/src/launch.rs | 52 ++++++++ crates/runner/src/admission.rs | 25 +++- crates/runner/src/launch.rs | 118 +++++++++++++++-- crates/runner/src/process.rs | 217 +++++++++++++++++++++++++++++-- crates/server/src/mcp.rs | 78 +++++++++++ 6 files changed, 504 insertions(+), 20 deletions(-) diff --git a/crates/devguard/src/admission.rs b/crates/devguard/src/admission.rs index 2248b5e..3e4c62d 100644 --- a/crates/devguard/src/admission.rs +++ b/crates/devguard/src/admission.rs @@ -268,6 +268,30 @@ impl Owner { self.attempt_request(admission, Request::Observe, effective_uid(), self.pid()) } + /// Ask DevGuard to send SIGKILL to every identity of the attempt's scope that it can + /// recheck (CSRG-U5): the group's members while the group provably is still the scope's, and + /// the escapes it knows. Delivery is not release evidence: DevGuard releases the scope only + /// once it has observed it ended. + pub fn terminate(&self, admission: &Admission) -> Termination { + let Some(wire) = self.wire(admission) else { + return Termination::NotSignalled; + }; + let Ok((mut client, _)) = self.open_with( + effective_uid(), + crate::launch::launch_compatibility(), + self.pid(), + ) else { + return Termination::NotSignalled; + }; + match client.terminate(wire.key, devguard_client::protocol::StopSignal::Kill) { + Ok(result) => Termination::Signalled { + signalled: result.signalled, + complete: result.complete, + }, + Err(_) => Termination::NotSignalled, + } + } + pub(crate) fn admit_with(&self, admission: &Admission, uid: u32, pid: u32) -> Answer { self.attempt_request(admission, Request::Admit, uid, pid) } @@ -376,6 +400,16 @@ impl Owner { } } +/// What DevGuard did for [`Owner::terminate`] (CSRG-U5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Termination { + /// DevGuard signalled this many identities of the scope; `complete` when it could observe + /// and signal every target. + Signalled { signalled: u32, complete: bool }, + /// DevGuard refused, or the request did not reach it: nothing is known to be signalled. + NotSignalled, +} + /// Why this owner holds no helper for a launch grant (CSRG-U4). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Abandon { diff --git a/crates/devguard/src/launch.rs b/crates/devguard/src/launch.rs index fc13f02..2f71962 100644 --- a/crates/devguard/src/launch.rs +++ b/crates/devguard/src/launch.rs @@ -1111,6 +1111,58 @@ pub(crate) mod tests { assert_ne!(charged(&provisioned), Budget::ZERO); } + #[test] + fn terminate_kills_the_whole_scope_and_it_is_released() { + let Some(helper) = helper_bin() else { return }; + let provisioned = admitting(); + let owner = provisioned.owner(); + let script = "sleep 30 /dev/null 2>&1 & exec sleep 30"; + let admission = admission("cs-terminate-1", &["/bin/sh", "-c", script]); + admitted(&owner, &admission); + let permit = granted(&owner, &admission); + let invocation = owner + .helper( + &helper, + &admission, + permit, + Path::new("/bin/sh"), + &["-c".into(), script.into()], + ) + .unwrap(); + let mut child = spawn(&invocation, Stdio::null()); + assert_eq!(invocation.into_transcript().read(), TranscriptEnd::Started); + // Until the root has exec'd its last command, its background child may not exist. + std::thread::sleep(Duration::from_millis(300)); + // The root and its descendant both: a kill of the root alone would leave the other. + let termination = owner.terminate(&admission); + assert!( + matches!( + termination, + crate::admission::Termination::Signalled { + signalled: 2.., + complete: true + } + ), + "{termination:?}" + ); + exited_unreaped(child.id()); + let _ = observed(&owner, &admission); + child.wait().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let ended = loop { + let attempt = observed(&owner, &admission); + if attempt.phase == Phase::Released || Instant::now() > deadline { + break attempt; + } + std::thread::sleep(Duration::from_millis(200)); + }; + assert_eq!( + (ended.phase, ended.release, ended.tracking_lost), + (Phase::Released, Some(Release::ScopeTerminated), false) + ); + assert_eq!(charged(&provisioned), Budget::ZERO); + } + #[test] fn a_lost_grant_is_found_committed_and_released_as_never_received() { let provisioned = admitting(); diff --git a/crates/runner/src/admission.rs b/crates/runner/src/admission.rs index 7017c0f..580c19a 100644 --- a/crates/runner/src/admission.rs +++ b/crates/runner/src/admission.rs @@ -65,6 +65,8 @@ pub trait AttemptAuthority: Send + Sync + 'static { /// Have DevGuard observe the attempt's scope now (CSRG-U5): while its exited root is still /// unreaped, so that members left in its group stay tracked, and again after the reap. fn observe(&self, admission: &Admission) -> Answer; + /// Have DevGuard kill the attempt's whole scope (CSRG-U5). Delivery is not release evidence. + fn terminate(&self, admission: &Admission) -> dg::Termination; /// The launch helper's invocation for the attempt's grant (CSRG-U4). fn helper( &self, @@ -101,6 +103,9 @@ impl AttemptAuthority for devguard::Owner { fn observe(&self, admission: &Admission) -> Answer { devguard::Owner::observe(self, admission) } + fn terminate(&self, admission: &Admission) -> dg::Termination { + devguard::Owner::terminate(self, admission) + } fn helper( &self, helper: &Path, @@ -1094,7 +1099,9 @@ pub(crate) mod tests { grants: Mutex>, abandons: Mutex>, admit_delay: Mutex, - calls: [AtomicUsize; 6], + /// How long `observe` and `terminate` take, as an authority that stopped answering. + stall: Mutex, + calls: [AtomicUsize; 7], seen: Mutex>, pub(crate) abandoned_for: Mutex>, } @@ -1125,6 +1132,11 @@ pub(crate) mod tests { *self.admit_delay.lock().unwrap() = delay; self } + /// An authority that stops answering once the launch has been granted. + pub(crate) fn stalling(self: Arc, stall: Duration) -> Arc { + *self.stall.lock().unwrap() = stall; + self + } pub(crate) fn count(&self, call: usize) -> usize { self.calls[call].load(Ordering::SeqCst) } @@ -1139,6 +1151,7 @@ pub(crate) mod tests { pub(crate) const BEGIN: usize = 3; pub(crate) const ABANDON: usize = 4; pub(crate) const OBSERVE: usize = 5; + pub(crate) const TERMINATE: usize = 6; /// A permit as a test authority would grant one. pub(crate) const PERMIT: &str = @@ -1201,8 +1214,18 @@ pub(crate) mod tests { fn observe(&self, admission: &Admission) -> Answer { self.calls[OBSERVE].fetch_add(1, Ordering::SeqCst); self.seen.lock().unwrap().push(admission.clone()); + std::thread::sleep(*self.stall.lock().unwrap()); Answer::Unknown } + fn terminate(&self, admission: &Admission) -> dg::Termination { + self.calls[TERMINATE].fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(admission.clone()); + std::thread::sleep(*self.stall.lock().unwrap()); + dg::Termination::Signalled { + signalled: 1, + complete: true, + } + } fn helper( &self, helper: &Path, diff --git a/crates/runner/src/launch.rs b/crates/runner/src/launch.rs index dd8e713..e654f6b 100644 --- a/crates/runner/src/launch.rs +++ b/crates/runner/src/launch.rs @@ -370,17 +370,24 @@ impl PreparedExecution { return self.not_launched(PreparationOutcome::HelperNotCreated, attempt); }; // DevGuard observes the scope while the exited root still holds its PID, so members left - // in its group stay tracked, and again after the reap (CSRG-U5). Its answer changes - // nothing CodeSpace does with the process. - let observer: crate::process::ExitObserver = { + // in its group stay tracked, and again after the reap; and when CodeSpace kills the + // process, DevGuard is asked to kill the whole scope (CSRG-U5). Neither answer changes + // what CodeSpace does with the process. + let hooks = { let (authority, admission) = (self.authority.clone(), self.admission.clone()); - Arc::new(move |_| { - let _ = authority.observe(&admission); - }) + let (killer, killed) = (self.authority.clone(), self.admission.clone()); + crate::process::ProcessHooks { + observer: Some(Arc::new(move |_| { + let _ = authority.observe(&admission); + })), + on_kill: Some(Arc::new(move || { + let _ = killer.terminate(&killed); + })), + } }; let (spawned, transcript) = self .runner - .spawn_managed(&self.ws, request.clone(), slot, invocation, observer) + .spawn_managed(&self.ws, request.clone(), slot, invocation, hooks) .await; if spawned.is_err() { // No helper exists, and none will be created for this grant. @@ -672,7 +679,7 @@ mod tests { use super::*; use crate::admission::tests::{ attempt, governed, prepared, record, request, runner, Fake, Grant, ABANDON, ADMIT, BEGIN, - CANCEL, LOOKUP, OBSERVE, + CANCEL, LOOKUP, OBSERVE, TERMINATE, }; use crate::admission::Preparer; use crate::{InProcessRunner, Runner, RunnerReadProcess}; @@ -904,6 +911,44 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} } } + /// A request cancelled after the launch was committed (CSRG-U5): the launch runs on, so a + /// helper that never runs the command is still reaped and reported, and nothing replays it. + #[tokio::test] + async fn a_cancelled_request_does_not_drop_a_committed_launch() { + let helpers = helper_directory(); + let late = r#"sleep 1; report '{"phase":"refused","code":"invalid_transition","message":"fenced"}'; exit 125"#; + let helper = stand_in(helpers.path(), "late", "", late); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + for tty in [false, true] { + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let (marker, mut req) = marker_request(dir.path(), &format!("proc-cancelled-{tty}")); + req.tty = tty; + let cancelled = tokio::time::timeout( + Duration::from_millis(300), + launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))), + ) + .await; + assert!(cancelled.is_err(), "the request was not cancelled"); + // The launch, committed before the cancellation, still settles its helper. + for _ in 0..500 { + if fake.count(ABANDON) == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!( + *fake.abandoned_for.lock().unwrap(), + [Abandon::HelperExited], + "tty={tty}" + ); + assert_eq!(fake.count(BEGIN), 1); + assert!(!marker.exists(), "the command ran"); + assert_eq!(settled_slots(&runner).await, 0); + } + } + #[tokio::test] async fn a_helper_that_never_runs_the_command_is_reported_once_it_is_reaped() { let helpers = helper_directory(); @@ -1469,6 +1514,55 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} ); } + /// Once a governed process has started, an authority that stops answering does not take + /// CodeSpace's control of it (CSRG-U5): status comes from CodeSpace's own state, termination + /// and the timeout kill it at once, and its reaper waits at most its bound for an + /// observation before reaping it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_authority_that_stops_answering_leaves_process_control_to_codespace() { + let helpers = helper_directory(); + let helper = stand_in(helpers.path(), "ready", "", READY); + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + for (tty, timeout_ms) in [(false, 30_000), (true, 30_000), (false, 300), (true, 300)] { + let runner = runner(); + let fake = Fake::admitting([prepared()]).stalling(Duration::from_secs(5)); + let process = format!("proc-stalled-{tty}-{timeout_ms}"); + let mut req = request(&["/bin/sleep", "30"], &process); + req.tty = tty; + req.timeout_ms = timeout_ms; + let launch = + launched(launch(&fake, &runner, &ws, req, Some(launcher(helper.clone()))).await); + let status = runner.process_status(&launch.process_id).await.unwrap(); + assert_eq!(status.state, ProcessState::Running); + let started = std::time::Instant::now(); + let expected = if timeout_ms == 300 { + codespace_domain::ProcessTermination::Timeout + } else { + runner.terminate(&launch.process_id).await.unwrap(); + codespace_domain::ProcessTermination::Terminated + }; + let mut status = runner.process_status(&launch.process_id).await.unwrap(); + for _ in 0..500 { + if status.state == ProcessState::Exited { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + status = runner.process_status(&launch.process_id).await.unwrap(); + } + assert_eq!( + (status.state, status.termination), + (ProcessState::Exited, Some(expected)), + "tty={tty} timeout={timeout_ms}" + ); + // Not held by the stalled authority beyond the observer's bound. + let bound = Duration::from_millis(timeout_ms.min(1_000)) + + crate::process::OBSERVE_BOUND + + Duration::from_secs(1); + assert!(started.elapsed() < bound, "{:?}", started.elapsed()); + } + } + /// A governed process times out as any other: CodeSpace's own timeout kills it, and its /// status says so. #[tokio::test] @@ -1505,6 +1599,14 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }} "tty={tty}" ); assert_eq!(fake.count(ABANDON), 0); + // CodeSpace's kill also asked DevGuard to kill the whole scope, once (CSRG-U5). + for _ in 0..100 { + if fake.count(TERMINATE) == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!(fake.count(TERMINATE), 1, "tty={tty}"); } } diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index 59e18b7..a8f2ea4 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -114,6 +114,7 @@ struct Slot { output: Arc>, completed_at: Arc>>, lifecycle: Arc>, + on_kill: Option, } enum SessionIo { @@ -304,6 +305,29 @@ async fn observe(observer: &Option, point: ExitPoint) { let _ = tokio::time::timeout(OBSERVE_BOUND, call).await; } +/// A hook called when CodeSpace kills a process, on termination or timeout (CSRG-U5): on its +/// own blocking thread and never awaited, so the process's own kill does not depend on it; for +/// example to have a resource authority end the process's whole scope. +pub type KillHook = Arc; + +/// What a spawn adds to its process (CSRG-U5): a governed execution's exit observer and kill +/// hook. An ungoverned process has neither. +#[derive(Clone, Default)] +pub struct ProcessHooks { + pub observer: Option, + pub on_kill: Option, +} + +/// Run `hook` after CodeSpace's own kill, without waiting for it. +fn after_kill(hook: &Option) { + let Some(hook) = hook.clone() else { + return; + }; + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn_blocking(move || hook()); + } +} + struct SpawnCtx { cwd: PathBuf, cap: usize, @@ -312,7 +336,7 @@ struct SpawnCtx { completed_at: Arc>>, lifecycle: Arc>, process_id: String, - observer: Option, + hooks: ProcessHooks, } #[derive(Default)] @@ -526,15 +550,17 @@ impl InProcessRunner { } // The slot first: no process is created without one. let slot = self.reserve_slot(&req.process_id.0)?; - self.spawn_slotted(ws, req, slot, How::Direct, None).await + self.spawn_slotted(ws, req, slot, How::Direct, ProcessHooks::default()) + .await } /// Start a governed execution's launch helper through this runner's own pipe or PTY spawner /// (CSRG-U4), filling the slot its preparation took. The helper alone receives the /// invocation's two descriptors, and this process's copies are closed as soon as the spawn /// returns. The helper becomes the execution: from here it is a process like any other. - /// Its reaper calls `observer` before and after the reap (CSRG-U5). Returns the spawn's - /// result and the owner's end of the helper's transcript. + /// Its reaper calls `hooks.observer` before and after the reap, and CodeSpace's kills call + /// `hooks.on_kill` (CSRG-U5). Returns the spawn's result and the owner's end of the helper's + /// transcript. #[cfg(feature = "devguard")] pub(crate) async fn spawn_managed( &self, @@ -542,14 +568,14 @@ impl InProcessRunner { req: RunnerExecRequest, slot: SlotReservation, invocation: codespace_devguard::launch::HelperInvocation, - observer: ExitObserver, + hooks: ProcessHooks, ) -> ( Result, codespace_devguard::launch::Transcript, ) { let managed = Arc::new(Managed::new(invocation)); let result = self - .spawn_slotted(ws, req, slot, How::Managed(managed.clone()), Some(observer)) + .spawn_slotted(ws, req, slot, How::Managed(managed.clone()), hooks) .await; (result, managed.finish()) } @@ -560,7 +586,7 @@ impl InProcessRunner { req: RunnerExecRequest, slot: SlotReservation, how: How, - observer: Option, + hooks: ProcessHooks, ) -> Result { let cap = req.output_bytes_cap.max(1) as usize; let timeout = Duration::from_millis(req.timeout_ms.max(1)); @@ -575,7 +601,7 @@ impl InProcessRunner { completed_at: Arc::new(Mutex::new(None)), lifecycle: Arc::new(Mutex::new(Lifecycle::new())), process_id: req.process_id.0.clone(), - observer, + hooks, }; if req.tty { self.spawn_pty(ws, req, ctx, slot, how).await @@ -600,8 +626,9 @@ impl InProcessRunner { completed_at, lifecycle, process_id, - observer, + hooks, } = ctx; + let ProcessHooks { observer, on_kill } = hooks; let (mut child, launch) = match &how { How::Direct => { let launch = exec_launch(ws, &req)?; @@ -660,6 +687,7 @@ impl InProcessRunner { output: output.clone(), completed_at: completed_at.clone(), lifecycle: lifecycle.clone(), + on_kill: on_kill.clone(), }; { let mut map = self.inner.lock().expect("runner"); @@ -709,6 +737,7 @@ impl InProcessRunner { life.note_kill(KillIntent::Timeout); let _ = ch.start_kill(); drop(life); + after_kill(&on_kill); if let Ok(mut buf) = timeout_out.lock() { buf.timed_out = true; } @@ -735,8 +764,9 @@ impl InProcessRunner { completed_at, lifecycle, process_id, - observer, + hooks, } = ctx; + let ProcessHooks { observer, on_kill } = hooks; let launch = match &how { How::Direct => Some(exec_launch(ws, &req)?), #[cfg(feature = "devguard")] @@ -821,6 +851,7 @@ impl InProcessRunner { output: output.clone(), completed_at: completed_at.clone(), lifecycle: lifecycle.clone(), + on_kill: on_kill.clone(), }; { let mut map = self.inner.lock().expect("runner"); @@ -871,6 +902,7 @@ impl InProcessRunner { life.note_kill(KillIntent::Timeout); drop(life); timeout_session.kill(); + after_kill(&on_kill); if let Ok(mut buf) = timeout_out.lock() { buf.timed_out = true; } @@ -1147,6 +1179,7 @@ fn request_kill(slot: &Slot) -> Result { child .start_kill() .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; + after_kill(&slot.on_kill); Ok(true) } SessionIo::Pty { session, .. } => { @@ -1156,6 +1189,7 @@ fn request_kill(slot: &Slot) -> Result { } life.note_kill(KillIntent::Terminated); session.kill(); + after_kill(&slot.on_kill); Ok(true) } } @@ -2204,7 +2238,16 @@ mod tests { req.tty = tty; let slot = runner.reserve_slot(&process_id.0).unwrap(); runner - .spawn_slotted(ws, req, slot, How::Direct, Some(observer)) + .spawn_slotted( + ws, + req, + slot, + How::Direct, + ProcessHooks { + observer: Some(observer), + on_kill: None, + }, + ) .await .unwrap(); } @@ -2310,6 +2353,158 @@ mod tests { assert_eq!(*seen.lock().unwrap(), [ExitPoint::AfterReap]); } + /// Spawn `argv` with a kill hook that counts its calls. + async fn spawn_counting_kills( + runner: &InProcessRunner, + ws: &Workspace, + process_id: &ProcessId, + argv: &[&str], + tty: bool, + timeout_ms: u64, + ) -> Arc { + let kills = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut req = RunnerExecRequest::for_host( + argv.iter().map(|arg| arg.to_string()).collect(), + process_id.clone(), + Profile::WorkspaceWrite, + ); + req.tty = tty; + req.timeout_ms = timeout_ms; + let slot = runner.reserve_slot(&process_id.0).unwrap(); + let counted = kills.clone(); + runner + .spawn_slotted( + ws, + req, + slot, + How::Direct, + ProcessHooks { + observer: None, + on_kill: Some(Arc::new(move || { + counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })), + }, + ) + .await + .unwrap(); + kills + } + + async fn counted(kills: &std::sync::atomic::AtomicUsize, expected: usize) -> usize { + for _ in 0..100 { + if kills.load(std::sync::atomic::Ordering::SeqCst) >= expected { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + // Long enough for a call that should not happen. + tokio::time::sleep(Duration::from_millis(100)).await; + kills.load(std::sync::atomic::Ordering::SeqCst) + } + + #[tokio::test] + async fn the_kill_hook_runs_once_for_each_kill_codespace_makes() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + for tty in [false, true] { + // A termination kills it once; a second one finds it gone. + let process_id = ProcessId(format!("proc-kill-hook-terminated-{tty}")); + let kills = spawn_counting_kills( + &runner, + &ws, + &process_id, + &["/bin/sleep", "30"], + tty, + 30_000, + ) + .await; + runner.terminate(&process_id).await.unwrap(); + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.termination, Some(ProcessTermination::Terminated)); + runner.terminate(&process_id).await.unwrap(); + assert_eq!(counted(&kills, 1).await, 1, "tty={tty}"); + // The timeout kills it once. + let process_id = ProcessId(format!("proc-kill-hook-timeout-{tty}")); + let kills = + spawn_counting_kills(&runner, &ws, &process_id, &["/bin/sleep", "30"], tty, 100) + .await; + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.termination, Some(ProcessTermination::Timeout)); + assert_eq!(counted(&kills, 1).await, 1, "tty={tty}"); + // A process that ended on its own is not killed. + let process_id = ProcessId(format!("proc-kill-hook-exited-{tty}")); + let kills = spawn_counting_kills( + &runner, + &ws, + &process_id, + &["/bin/sh", "-c", "exit 0"], + tty, + 30_000, + ) + .await; + wait_exited(&runner, &process_id).await; + runner.terminate(&process_id).await.unwrap(); + assert_eq!(counted(&kills, 0).await, 0, "tty={tty}"); + } + } + + /// Root exit, output EOF and the workspace's release are separate events (CSRG-U5): a + /// descendant that keeps the output open delays the EOF and the release, not the root's + /// exit. + #[tokio::test] + async fn root_exit_output_eof_and_release_are_separate_events() { + // The Linux command sandbox's helper outlives the command's descendants. + if crate::linux_sandbox_available() { + return; + } + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let released = Arc::new(Mutex::new(Vec::::new())); + let runner = { + let released = released.clone(); + InProcessRunner::new(Arc::new(move |id: &str| { + released.lock().unwrap().push(id.to_owned()) + })) + }; + let process_id = ProcessId("proc-separate-events".into()); + let req = RunnerExecRequest::for_host( + vec!["/bin/sh".into(), "-c".into(), "sleep 1 & echo root".into()], + process_id.clone(), + Profile::WorkspaceWrite, + ); + runner.exec(&ws, req).await.unwrap(); + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.exit_code, Some(0)); + // The root has exited; its descendant still holds the output open. + let read = runner + .read_process(RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + assert!(!read.eof); + assert!(released.lock().unwrap().is_empty()); + // Once the descendant has ended, the output ends and the workspace is released. + for _ in 0..250 { + if !released.lock().unwrap().is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let read = runner + .read_process(RunnerReadProcess { + process_id: process_id.clone(), + cursor: 0, + }) + .await + .unwrap(); + assert!(read.eof); + assert_eq!(read.chunk, "root\n"); + assert_eq!(*released.lock().unwrap(), [process_id.0]); + } + #[tokio::test] async fn an_exited_child_keeps_its_one_reaper_whatever_else_acts_on_it() { let dir = tempdir().unwrap(); diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index bd82aa6..5210746 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -3411,6 +3411,84 @@ mod devguard_tests { assert_eq!(released(&mut fixture), (Vec::new(), 0)); } + /// Once a governed process has started, losing the resource authority does not take + /// CodeSpace's own control of it (CSRG-U5): its status comes from CodeSpace's handle, it is + /// terminated as any other process, and its workspace is free again afterwards. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_lost_authority_leaves_a_governed_process_under_codespaces_control() { + let Some(fixture) = FixtureAuthority::start(2) else { + return; + }; + let Some(helper) = launch_helper() else { + return; + }; + if !fixture.native { + return; + } + let dir = tempfile::tempdir().unwrap(); + let cli = register_cli( + &fixture.settings, + &[ + "--runner", + "in-process", + "--devguard-launch-helper", + helper.to_str().unwrap(), + ], + ); + let store = Arc::new(Store::memory().unwrap()); + let handler = CodeSpace::from_cli( + &cli, + launch_registry_with(dir.path(), 2), + store.clone(), + RuntimeBackend::in_process(release(&store)), + ); + let mut running = Vec::new(); + for (workspace, tty) in [("gov-0", false), ("gov-1", true)] { + let result = executed(&handler, exec_params(workspace, &["/bin/sleep", "30"], tty)) + .await + .unwrap(); + running.push((workspace, result.process_id)); + } + // The authority goes away while both processes run. + drop(fixture); + let started = std::time::Instant::now(); + for (workspace, process_id) in &running { + let status = handler + .process_status(Parameters(ProcessStatusParams { + process_id: process_id.clone(), + })) + .await + .unwrap() + .0; + assert_eq!(status.state, ProcessState::Running, "{workspace}"); + handler + .terminate_process(Parameters(TerminateProcessParams { + process_id: process_id.clone(), + })) + .await + .unwrap(); + } + for (workspace, process_id) in &running { + let (_, status) = finished(&handler, process_id).await; + assert_eq!( + status.termination, + Some(codespace_domain::ProcessTermination::Terminated), + "{workspace}" + ); + drop( + store + .try_acquire_write(workspace) + .expect("the lease is released"), + ); + } + // At most the reaper's two-second bound for an observation that cannot be answered. + assert!( + started.elapsed() < std::time::Duration::from_secs(4), + "{:?}", + started.elapsed() + ); + } + /// A governed pipe execution whose root exits leaving a descendant (CSRG-U5): CodeSpace's /// one reaper has DevGuard observe the scope while the exited root still holds its PID, so /// the descendant keeps the attempt charged, and DevGuard releases it once the descendant From c143b2c19d0344845350118cda517053c7d439ee Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 00:59:15 +0900 Subject: [PATCH 4/6] runner, server, store: a refused resume proven not started keeps its approval (CSRG-U5) The CS-RG design binds approvals to the attempt's lifecycle: a resource refusal leaves the approval's hold unconsumed, and only a refusal proven not to have started the matching attempt permits the approval's reuse; a timeout, a lost answer or a missing process handle is not that proof. The gateway marked a resume as dispatching before the owner prepared the execution, so a refusal consumed the approval with the error, and a second resume replayed the refusal. ManagedPreparation::proves_not_started holds when the authority's state of the attempt proves the command never started: never asked, not recorded, denied, cancelled, expired, or released on the owner's report that it holds no helper. For such a refusal of a resumed approval, the gateway gives the hold back (Store::return_resume: resuming to granted), finish_resume persists nothing for it, and the approval can be resumed again, as a new attempt. Every other refusal, and any unknown, launched or scope-ended attempt, consumes the approval as before, and an interrupted resume is still reported ambiguous without a restart. Tests: the store gives back only an approval being resumed, keeps it granted with nothing persisted, and claims it for execution again; a resume the fixture authority denies keeps its approval, and resuming again is refused for a new attempt instead of replaying the refusal; U3's starts-nothing check now expects that new attempt, and one more owner session for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/runner/src/admission.rs | 16 ++++ crates/server/src/mcp.rs | 169 +++++++++++++++++++++++++++++---- crates/store/src/approvals.rs | 68 ++++++++++++- 3 files changed, 231 insertions(+), 22 deletions(-) diff --git a/crates/runner/src/admission.rs b/crates/runner/src/admission.rs index 580c19a..63bd471 100644 --- a/crates/runner/src/admission.rs +++ b/crates/runner/src/admission.rs @@ -366,6 +366,22 @@ pub struct ManagedPreparation { } impl ManagedPreparation { + /// Whether the resource authority's state of the attempt proves that its command never + /// started (CSRG-U5): never asked, refused, denied, cancelled or expired before any launch, + /// or released on this owner's report that it holds no helper. Only such a refusal lets the + /// execution's approval be used again; an unknown, launched or scope-ended attempt does not. + pub fn proves_not_started(&self) -> bool { + matches!( + self.attempt, + AttemptState::NotAsked + | AttemptState::NotRecorded + | AttemptState::Denied + | AttemptState::Cancelled + | AttemptState::Expired + | AttemptState::Released + ) + } + /// The tool error for a preparation that started nothing, or a launch whose command did not /// start (CSRG-U3, U4). Only enumerations, numbers and identifiers are stated. pub fn into_error_body(self, workspace_id: &str) -> ErrorBody { diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 5210746..d1ff93a 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -891,7 +891,7 @@ impl CodeSpace { // so it is released with the answer whatever the attempt's own state. let refused = self.prepare_governed(ws, req).await; reservation.abort(); - return Err(refused); + return Err(self.refused_governed(resume, refused)); } // The owner may create a process from here on (CSRG-U4). reservation.arm_dispatch(); @@ -915,7 +915,7 @@ impl CodeSpace { Err(refused) => { // The command did not start; a helper that existed was reaped. reservation.abort(); - Err(refused) + Err(self.refused_governed(resume, refused)) } }; } @@ -946,6 +946,31 @@ impl CodeSpace { } } +/// A governed execution that did not start, and whether the resource authority proves that it +/// never started (CSRG-U5). +#[cfg(feature = "devguard")] +struct NotStarted { + body: ErrorBody, + proven: bool, +} + +#[cfg(feature = "devguard")] +impl NotStarted { + fn report(report: codespace_runner::admission::ManagedPreparation, workspace_id: &str) -> Self { + Self { + proven: report.proves_not_started(), + body: report.into_error_body(workspace_id), + } + } + + fn unproven(body: ErrorBody) -> Self { + Self { + body, + proven: false, + } + } +} + /// How a new execution in a workspace takes part in the resource authority. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Participating { @@ -985,17 +1010,20 @@ impl CodeSpace { /// Have the execution owner prepare `req` without launching it (CSRG-U3): an owner that /// cannot launch cancels each preparation, so it ends in a refusal and nothing is started. #[cfg(feature = "devguard")] - async fn prepare_governed(&self, ws: &Workspace, req: RunnerExecRequest) -> ErrorBody { + async fn prepare_governed(&self, ws: &Workspace, req: RunnerExecRequest) -> NotStarted { let Some(registration) = &self.registration else { - return ErrorBody::new(ErrorCode::Internal, "no execution owner registration"); + return NotStarted::unproven(ErrorBody::new( + ErrorCode::Internal, + "no execution owner registration", + )); }; let attempt_id = format!("cs-attempt-{}", Uuid::new_v4().simple()); match registration .prepare(&self.runner, ws, req, attempt_id) .await { - Ok(report) => report.into_error_body(&ws.id.0), - Err(err) => err, + Ok(report) => NotStarted::report(report, &ws.id.0), + Err(err) => NotStarted::unproven(err), } } @@ -1006,23 +1034,41 @@ impl CodeSpace { &self, ws: &Workspace, req: RunnerExecRequest, - ) -> Result { + ) -> Result { let Some(registration) = &self.registration else { - return Err(ErrorBody::new( + return Err(NotStarted::unproven(ErrorBody::new( ErrorCode::Internal, "no execution owner registration", - )); + ))); }; let attempt_id = format!("cs-attempt-{}", Uuid::new_v4().simple()); match registration .launch(&self.runner, ws, req, attempt_id) - .await? + .await + .map_err(NotStarted::unproven)? { codespace_runner::launch::LaunchReport::Launched(launch) => Ok(launch), codespace_runner::launch::LaunchReport::NotLaunched(report) => { - Err(report.into_error_body(&ws.id.0)) + Err(NotStarted::report(report, &ws.id.0)) + } + } + } + + /// The refusal of a governed execution. An approval being resumed gets its hold back when + /// the authority proves that the command never started (CSRG-U5); otherwise the resume + /// consumes it as before. + #[cfg(feature = "devguard")] + fn refused_governed(&self, resume: Option<&ApprovalId>, refused: NotStarted) -> ErrorBody { + if let (Some(approval_id), true) = (resume, refused.proven) { + if let Err(err) = self.store.return_resume(approval_id) { + tracing::warn!( + approval_id = %approval_id.0, + error = %err.message, + "the approval could not be given back; its resume consumes it" + ); } } + refused.body } #[cfg(not(feature = "devguard"))] @@ -2389,8 +2435,9 @@ mod devguard_tests { ], ) .await; - // Each preparation came from a session the owner opened for it. - assert_eq!(endpoint.accepted(), 3); + // Each preparation came from a session the owner opened for it: two executions, and + // two resumes of the one approval, which kept its hold after the first refusal (CSRG-U5). + assert_eq!(endpoint.accepted(), 4); // Existing process control never asks DevGuard. let started = handler .exec_command(Parameters(exec_params( @@ -2402,7 +2449,7 @@ mod devguard_tests { .unwrap() .0; assert_process_control_needs_no_registration(&handler, started.process_id).await; - assert_eq!(endpoint.accepted(), 3); + assert_eq!(endpoint.accepted(), 4); } /// Status, termination and the outcome of a running process, without a registration. @@ -2538,16 +2585,15 @@ mod devguard_tests { } }; check(&body, &raw); - let replayed = cs + // The refusal proves the command never started, so the approval kept its hold + // (CSRG-U5): resumed again, it is refused again for a new attempt, never replayed. + let again = cs .operation_resume(Parameters(OperationResumeParams { approval_id })) .await .err() .expect("refused"); - let replayed: ErrorBody = serde_json::from_str(&replayed).unwrap(); - assert_eq!( - (replayed.code, replayed.message.as_str()), - (body.code, body.message.as_str()) - ); + let again_body: ErrorBody = serde_json::from_str(&again).unwrap(); + check(&again_body, &again); drop( store .try_acquire_write("gov-confirm") @@ -3411,6 +3457,89 @@ mod devguard_tests { assert_eq!(released(&mut fixture), (Vec::new(), 0)); } + /// A resumed approval whose execution the resource authority denies keeps its hold + /// (CSRG-U5): the refusal proves the command never started, so resuming again asks for a + /// new attempt rather than replaying the refusal. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_denied_resume_keeps_its_approval_for_another_resume() { + let Some(fixture) = FixtureAuthority::start(2) else { + return; + }; + if !fixture.native { + return; + } + let dir = tempfile::tempdir().unwrap(); + let config = serde_json::json!({"workspaces": { + "short-confirm": { + "root": dir.path(), + "profile": "workspace-write", + "approvals": "confirm", + "resources": {"participation": "required", "request": { + "cpu_milli": 100, "memory_bytes": 1u64 << 60, "tasks": 4}}, + }, + }}); + let cli = register_cli(&fixture.settings, &["--runner", "in-process"]); + let store = Arc::new(Store::memory().unwrap()); + let handler = CodeSpace::from_cli( + &cli, + Registry::load_json(&config.to_string()).unwrap(), + store.clone(), + RuntimeBackend::in_process(release(&store)), + ); + let held = handler + .exec_command(Parameters(exec_params( + "short-confirm", + &["/usr/bin/true"], + false, + ))) + .await + .err() + .expect("held"); + let held: ErrorBody = serde_json::from_str(&held).unwrap(); + assert_eq!(held.code, ErrorCode::ApprovalRequired); + let approval_id = ApprovalId(held.approval_id.unwrap()); + handler + .approval_resolve(Parameters(codespace_domain::ApprovalResolveParams { + approval_id: approval_id.clone(), + decision: codespace_domain::ApprovalDecision::Grant, + })) + .await + .unwrap(); + let mut attempts = Vec::new(); + for _ in 0..2 { + let raw = handler + .operation_resume(Parameters(OperationResumeParams { + approval_id: approval_id.clone(), + })) + .await + .err() + .expect("denied"); + let body: ErrorBody = serde_json::from_str(&raw).unwrap(); + assert_eq!(body.code, ErrorCode::ResourceUnavailable, "{raw}"); + assert!( + body.message.ends_with(": denied; nothing was started"), + "{raw}" + ); + let attempt = body + .message + .split("attempt `") + .nth(1) + .and_then(|rest| rest.split('`').next()) + .unwrap() + .to_owned(); + assert!( + !attempts.contains(&attempt), + "the refusal was replayed: {raw}" + ); + attempts.push(attempt); + } + drop( + store + .try_acquire_write("short-confirm") + .expect("the lease is released"), + ); + } + /// Once a governed process has started, losing the resource authority does not take /// CodeSpace's own control of it (CSRG-U5): its status comes from CodeSpace's handle, it is /// terminated as any other process, and its workspace is free again afterwards. diff --git a/crates/store/src/approvals.rs b/crates/store/src/approvals.rs index 7809e76..13a3445 100644 --- a/crates/store/src/approvals.rs +++ b/crates/store/src/approvals.rs @@ -256,13 +256,17 @@ impl Store { approval_id: &ApprovalId, result: Result<&OperationResumeResult, &ErrorBody>, ) -> Result<(), ErrorBody> { - let queued = { + let state = { let conn = self.conn.lock().expect("sqlite mutex"); load(&conn, &approval_id.0)? .ok_or_else(|| not_found(approval_id))? .state - == ApprovalState::Queued.as_str() }; + // A hold given back unconsumed (`return_resume`) stays granted: nothing to persist. + if state == ApprovalState::Granted.as_str() { + return Ok(()); + } + let queued = state == ApprovalState::Queued.as_str(); if queued && matches!( &result, @@ -312,6 +316,25 @@ impl Store { Ok(()) } + /// Give a resumed approval back unconsumed (CSRG-U5): its execution was refused with proof + /// that it never started, so the hold stays granted and may be resumed again. Only an + /// approval being resumed (`resuming`) is given back. + pub fn return_resume(&self, approval_id: &ApprovalId) -> Result<(), ErrorBody> { + let conn = self.conn.lock().expect("sqlite mutex"); + let updated = conn + .execute( + "UPDATE approvals + SET state = 'granted' + WHERE approval_id = ?1 AND state = 'resuming'", + params![approval_id.0], + ) + .map_err(db_err)?; + if updated != 1 { + return Err(conflict("approval is not being resumed")); + } + Ok(()) + } + pub fn mark_resuming(&self, approval_id: &ApprovalId) -> Result<(), ErrorBody> { let conn = self.conn.lock().expect("sqlite mutex"); let row = load(&conn, &approval_id.0)?.ok_or_else(|| not_found(approval_id))?; @@ -697,6 +720,47 @@ mod tests { assert!(result_json.is_none()); } + /// A resume refused with proof that its execution never started gives the hold back + /// (CSRG-U5): the approval stays granted, nothing is persisted, and it can be resumed again. + #[test] + fn a_hold_given_back_stays_granted_and_resumes_again() { + let store = Store::memory().unwrap(); + let params = patch_params(); + let created = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + &Store::fingerprint(¶ms), + serde_json::to_value(¶ms).unwrap(), + ) + .unwrap(); + store + .resolve_approval(&created.approval_id, ApprovalDecision::Grant) + .unwrap(); + let refused = ErrorBody::new(ErrorCode::ResourceUnavailable, "denied"); + { + let _guard = match store.claim_resume(&created.approval_id).unwrap() { + ResumeClaim::Execute(_, guard) => guard, + other => panic!("expected execute, got {other:?}"), + }; + // Only an approval being resumed is given back. + assert!(store.return_resume(&created.approval_id).is_err()); + store.mark_resuming(&created.approval_id).unwrap(); + store.return_resume(&created.approval_id).unwrap(); + store + .finish_resume(&created.approval_id, Err(&refused)) + .unwrap(); + } + let (state, _, result_json) = store.inspect_approval(&created.approval_id).unwrap(); + assert_eq!(state, ApprovalState::Granted); + assert!(result_json.is_none()); + // Resumed again, it executes again rather than replaying the refusal. + assert!(matches!( + store.claim_resume(&created.approval_id).unwrap(), + ResumeClaim::Execute(_, _) + )); + } + #[test] fn restart_resuming_is_reconcile() { let store = Store::memory().unwrap(); From 956b3b87232cfab7e59dfd6951bdd147fc64a8d5 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 01:18:26 +0900 Subject: [PATCH 5/6] runner, server: no-replay, release and race cases of the governed lifecycle (CSRG-U5) The order lists the ambiguity, release and race cases a governed execution must handle (sections 7 to 9). Most are covered by the tests of U3, U4 and the earlier U5 commits; this adds tests for the rest. No product code changes. - Authority restart while a governed process runs: CodeSpace keeps the process, its status and its workspace lease, and the owner registers again, so a new execution is admitted and released. DevGuard no longer knows the scope, so the ended attempt stays charged as Suspect, and resuming its approval again returns the same process. The gateway's fixture authority gains a `restart` command: it stops the authority and serves the same journal again, and answers once it admits. - A worker lost after it took a governed launch: the dispatch is unknown, the launch is never sent again, the workspace stays busy, and the resumed approval keeps that answer. - Shutdown: a running child is killed and, like a child that exited just before and is not reaped yet, left to tokio's orphan queue, never also to CodeSpace's reaper. - DevGuard's record: only a release on the owner's no-helper report reads as never started. A Suspect attempt (after a restart or a lost track) reads as launched, and a scope-ended or previous-boot release as ended. Only a state that proves no start gives an approval back. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/devguard/examples/fixture_authority.rs | 36 ++- crates/runner/src/admission.rs | 70 ++++++ crates/runner/src/process.rs | 69 ++++++ crates/server/src/devguard.rs | 42 +++- crates/server/src/mcp.rs | 229 ++++++++++++++++++ 5 files changed, 432 insertions(+), 14 deletions(-) diff --git a/crates/devguard/examples/fixture_authority.rs b/crates/devguard/examples/fixture_authority.rs index 206ce93..afdc82a 100644 --- a/crates/devguard/examples/fixture_authority.rs +++ b/crates/devguard/examples/fixture_authority.rs @@ -9,8 +9,9 @@ //! It prints one JSON line with the settings (none is a secret: the secret stays in its file) //! once the authority admits, then answers each `instances` line on standard input with a JSON //! line listing the consumer's registered instances, and each `charged` line with one listing -//! the attempts that still hold resources and the budget they hold (CSRG-U3). It stops at the -//! end of its input. Built only as an +//! the attempts that still hold resources and the budget they hold (CSRG-U3). A `restart` line +//! stops the authority and serves its journal again, as after a restart, and is answered once +//! it admits again (CSRG-U5). It stops at the end of its input. Built only as an //! example, from this crate's development dependencies; no product links it. use std::io::{BufRead, Write}; @@ -33,7 +34,7 @@ fn main() { .map(|limit| limit.to_str().unwrap().parse().unwrap()) .unwrap_or(2); let paths = AuthorityPaths::fixture(&base); - let fixture = Fixture::start(&base, &paths, max_instances); + let mut fixture = Fixture::start(&base, &paths, max_instances); let mut out = std::io::stdout().lock(); writeln!(out, "{}", fixture.settings).unwrap(); out.flush().unwrap(); @@ -41,6 +42,10 @@ fn main() { match line.unwrap().trim() { "instances" => writeln!(out, "{}", fixture.instances()).unwrap(), "charged" => writeln!(out, "{}", fixture.charged()).unwrap(), + "restart" => { + fixture = fixture.restart(&base); + writeln!(out, "{}", json!({"restarted": true})).unwrap() + } other => panic!("unknown command {other:?}"), } out.flush().unwrap(); @@ -148,6 +153,31 @@ impl Fixture { json!({"attempts": [], "committed": {"cpu_milli": 0, "memory_bytes": 0, "tasks": 0}}) } + /// Stop serving and serve the same journal again, as after a restart: DevGuard turns + /// every committed attempt and registered instance Suspect. + #[cfg(target_os = "macos")] + fn restart(self, base: &Path) -> Self { + let Self { + settings, + authority, + } = self; + authority.stop().unwrap(); + let authority = devguard_daemon::fixture::TestAuthority::restart(base).unwrap(); + authority + .wait_until_admitting(std::time::Duration::from_secs(10)) + .unwrap(); + Self { + settings, + authority, + } + } + + /// Without native evidence nothing is launched, so no test restarts this authority. + #[cfg(not(target_os = "macos"))] + fn restart(self, _base: &Path) -> Self { + panic!("a restart is only exercised with native evidence"); + } + fn stop(self) { #[cfg(target_os = "macos")] self.authority.stop().unwrap(); diff --git a/crates/runner/src/admission.rs b/crates/runner/src/admission.rs index 63bd471..27e52f7 100644 --- a/crates/runner/src/admission.rs +++ b/crates/runner/src/admission.rs @@ -1770,6 +1770,76 @@ pub(crate) mod tests { assert_nothing_started(dir.path(), &runner); } + /// Only the authority's states that prove a command never started let its approval be used + /// again (CSRG-U5): a timeout, a lost answer, a launched or settled scope, or any state the + /// owner cannot read, does not. + #[test] + fn only_a_state_that_proves_no_start_releases_an_approval() { + let report = |attempt| ManagedPreparation { + attempt_id: "cs-attempt-1".into(), + process_id: ProcessId("proc-1".into()), + outcome: PreparationOutcome::Unknown, + attempt, + account: None, + }; + for attempt in [ + AttemptState::NotAsked, + AttemptState::NotRecorded, + AttemptState::Denied, + AttemptState::Cancelled, + AttemptState::Expired, + AttemptState::Released, + ] { + assert!(report(attempt).proves_not_started(), "{attempt:?}"); + } + for attempt in [ + AttemptState::Launched, + AttemptState::ScopeEnded, + AttemptState::Other, + AttemptState::Unknown, + ] { + assert!(!report(attempt).proves_not_started(), "{attempt:?}"); + } + } + + /// What DevGuard's record of a possibly launched attempt shows (CSRG-U5): only a release on + /// the owner's no-helper report proves no start. A suspect attempt (after an authority + /// restart, or with lost tracking) is launched as far as the owner knows, and a release by + /// scope end or after a reboot proves nothing about the start. + #[test] + fn only_a_no_helper_release_reads_as_never_started() { + let record = |phase, release| dg::Attempt { + release, + ..record(phase, None) + }; + for (phase, release, expected) in [ + ( + dg::Phase::Released, + Some(dg::Release::NoHelperCreated), + AttemptState::Released, + ), + ( + dg::Phase::Released, + Some(dg::Release::ScopeTerminated), + AttemptState::ScopeEnded, + ), + ( + dg::Phase::Released, + Some(dg::Release::PreviousBoot), + AttemptState::ScopeEnded, + ), + (dg::Phase::Suspect, None, AttemptState::Launched), + (dg::Phase::RunAuthorized, None, AttemptState::Launched), + (dg::Phase::LaunchCommitted, None, AttemptState::Launched), + ] { + assert_eq!( + attempt_state(&record(phase, release)), + expected, + "{phase:?} {release:?}" + ); + } + } + #[test] fn the_meaning_follows_the_command_workspace_and_request() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index a8f2ea4..e65c48d 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -2449,6 +2449,75 @@ mod tests { } } + /// At shutdown each child's reaper and handles go with the runtime (CSRG-U5): a running + /// child is killed, and it, like a child that exited just before and that CodeSpace has not + /// reaped yet, is left to tokio's orphan queue, which reaps it once. Nothing outlives the + /// runner. A governed child is then not observed before that reap. + #[test] + fn a_shutdown_leaves_each_child_to_one_reaper() { + for (name, argv, exited) in [ + ("proc-shutdown-running", &["/bin/sleep", "30"][..], false), + ( + "proc-shutdown-exited", + &["/bin/sh", "-c", "exit 0"][..], + true, + ), + ] { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let process_id = ProcessId(name.into()); + let pid = runtime.block_on(async { + let runner = InProcessRunner::new(Arc::new(|_| {})); + let req = RunnerExecRequest::for_host( + argv.iter().map(|arg| arg.to_string()).collect(), + process_id.clone(), + Profile::WorkspaceWrite, + ); + if exited { + // Its reaper is held, so the runtime goes while it is exited and unreaped. + reaper_gate::hold(&process_id.0); + } + runner.exec(&ws, req).await.unwrap(); + if exited { + exited_but_unreaped(&runner, &process_id).await; + } + pipe_child_pid(&runner, &process_id) + }); + drop(runtime); + reaper_gate::release(&process_id.0); + assert_eq!(reaper_gate::reaps(&process_id.0), 0, "{name}"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + // SAFETY: an all-zero `siginfo_t` is valid; `WNOWAIT` leaves the child waitable. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is a valid `siginfo_t` for `waitid` to fill. + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + // Exited and not yet reaped, or already reaped by the orphan queue. + // SAFETY: `waitid` filled `info`, or left the zero it was given. + if (result == 0 && unsafe { info.si_pid() } != 0) || result == -1 { + break; + } + assert!( + Instant::now() < deadline, + "{name}: {pid} outlived the runner" + ); + std::thread::sleep(Duration::from_millis(20)); + } + } + } + /// Root exit, output EOF and the workspace's release are separate events (CSRG-U5): a /// descendant that keeps the output open delays the EOF and the release, not the root's /// exit. diff --git a/crates/server/src/devguard.rs b/crates/server/src/devguard.rs index 8f065f1..6d1a1d9 100644 --- a/crates/server/src/devguard.rs +++ b/crates/server/src/devguard.rs @@ -1296,15 +1296,20 @@ pub(crate) mod tests { }) } - /// The authority's registered `codespace` instances: identity and process ID. - pub(crate) fn instances(&mut self) -> Vec<(String, u32)> { + /// The fixture's one-line answer to `command`. + fn ask(&mut self, command: &str) -> serde_json::Value { use std::io::BufRead; let stdin = self.stdin.as_mut().unwrap(); - stdin.write_all(b"instances\n").unwrap(); + writeln!(stdin, "{command}").unwrap(); stdin.flush().unwrap(); let mut line = String::new(); self.stdout.read_line(&mut line).unwrap(); - let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + serde_json::from_str(&line).unwrap() + } + + /// The authority's registered `codespace` instances: identity and process ID. + pub(crate) fn instances(&mut self) -> Vec<(String, u32)> { + let value = self.ask("instances"); let mut instances: Vec<_> = value .as_array() .unwrap() @@ -1322,13 +1327,7 @@ pub(crate) mod tests { /// The attempts that still hold resources, and the tasks they hold. pub(crate) fn charged(&mut self) -> (Vec, u64) { - use std::io::BufRead; - let stdin = self.stdin.as_mut().unwrap(); - stdin.write_all(b"charged\n").unwrap(); - stdin.flush().unwrap(); - let mut line = String::new(); - self.stdout.read_line(&mut line).unwrap(); - let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + let value = self.ask("charged"); let attempts = value["attempts"] .as_array() .unwrap() @@ -1337,6 +1336,27 @@ pub(crate) mod tests { .collect(); (attempts, value["committed"]["tasks"].as_u64().unwrap()) } + + /// The attempts that still hold resources, each with its phase (CSRG-U5). + pub(crate) fn phases(&mut self) -> Vec<(String, String)> { + let value = self.ask("charged"); + value["attempts"] + .as_array() + .unwrap() + .iter() + .map(|attempt| { + ( + attempt["attempt_id"].as_str().unwrap().to_owned(), + attempt["phase"].as_str().unwrap().to_owned(), + ) + }) + .collect() + } + + /// Stop the authority and serve its journal again, as after a restart (CSRG-U5). + pub(crate) fn restart(&mut self) { + assert_eq!(self.ask("restart"), serde_json::json!({"restarted": true})); + } } impl Drop for FixtureAuthority { diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index d1ff93a..944ea48 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -2874,6 +2874,100 @@ mod devguard_tests { ); } + /// A worker lost after it took a governed launch may have started the command (CSRG-U5): + /// the gateway reports the dispatch unknown, never sends the launch again, keeps the + /// workspace busy, and the resumed approval keeps that answer, so resuming it again returns + /// the same process rather than a new attempt. + #[tokio::test] + async fn a_launch_lost_with_the_worker_is_unknown_and_never_sent_again() { + use codespace_runner::{ + read_frame, write_frame, RunnerOp, RunnerOpResult, UdsRunner, WireEnvelope, + WIRE_PROTOCOL, + }; + use std::sync::atomic::AtomicUsize; + let dir = short_directory(); + let (client, mut worker) = tokio::net::UnixStream::pair().unwrap(); + let launches = Arc::new(AtomicUsize::new(0)); + let seen = launches.clone(); + tokio::spawn(async move { + while let Ok(Some(frame)) = read_frame(&mut worker).await { + let request: WireEnvelope = serde_json::from_slice(&frame).unwrap(); + match request.op { + Some(RunnerOp::Hello) => { + let hello = RunnerOpResult::Hello { + protocol: WIRE_PROTOCOL, + registration: true, + admission: true, + launch: true, + }; + let reply = WireEnvelope::response(request.request_id.unwrap(), Ok(hello)); + write_frame(&mut worker, &reply).await.unwrap(); + } + // Lost with the worker before it answers. + Some(RunnerOp::Launch { .. }) => { + seen.fetch_add(1, Ordering::SeqCst); + return; + } + _ => return, + } + } + }); + let store = Arc::new(Store::memory().unwrap()); + let runner = UdsRunner::from_stream(client, release(&store)); + runner.handshake().await.unwrap(); + let handler = CodeSpace::with_store_and_runner( + participation_registry(dir.path(), "required"), + store.clone(), + RuntimeBackend::Uds(runner), + ) + .with_registration(crate::devguard::Registration::new( + settings(dir.path(), dir.path().join("absent.sock")), + crate::config::RunnerMode::Uds, + true, + None, + )); + let held = handler + .exec_command(Parameters(exec_params( + "gov-confirm", + &["/usr/bin/true"], + false, + ))) + .await + .err() + .expect("held"); + let held: ErrorBody = serde_json::from_str(&held).unwrap(); + assert_eq!(held.code, ErrorCode::ApprovalRequired); + let approval_id = ApprovalId(held.approval_id.unwrap()); + handler + .approval_resolve(Parameters(codespace_domain::ApprovalResolveParams { + approval_id: approval_id.clone(), + decision: codespace_domain::ApprovalDecision::Grant, + })) + .await + .unwrap(); + let mut answers = Vec::new(); + for _ in 0..2 { + let resumed = handler + .operation_resume(Parameters(OperationResumeParams { + approval_id: approval_id.clone(), + })) + .await + .unwrap() + .0 + .exec_command + .expect("an exec result"); + assert_eq!(resumed.dispatch_status, ExecDispatchStatus::Unknown); + answers.push(resumed.process_id); + } + assert_eq!(answers[0], answers[1]); + assert_eq!(launches.load(Ordering::SeqCst), 1); + let busy = store + .try_acquire_write("gov-confirm") + .err() + .expect("the workspace stays busy"); + assert_eq!(busy.code, ErrorCode::WorkspaceBusy); + } + /// The UDS worker registers itself and the gateway does not, against DevGuard's fixture /// authority in its own process. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -3122,6 +3216,30 @@ mod devguard_tests { panic!("{process_id:?} did not exit: {output:?}"); } + /// `operation_resume`, again while it stops at the credential (see `past_the_credential`): + /// a resume refused before any admission keeps its approval (CSRG-U5). + async fn resumed( + handler: &CodeSpace, + approval_id: &ApprovalId, + ) -> Result { + past_the_credential( + |result: &Result| { + matches!(result, Err(body) + if body.message.contains("(registration: credential_unavailable")) + }, + || async move { + handler + .operation_resume(Parameters(OperationResumeParams { + approval_id: approval_id.clone(), + })) + .await + .map(|result| result.0) + .map_err(|raw| serde_json::from_str(&raw).unwrap()) + }, + ) + .await + } + /// Wait until DevGuard holds nothing for CodeSpace: it releases a launched attempt once its /// reconciler observes the scope's end, within about a second. fn released(fixture: &mut FixtureAuthority) -> (Vec, u64) { @@ -3670,6 +3788,117 @@ mod devguard_tests { assert_eq!(released(&mut fixture), (Vec::new(), 0)); } + /// The resource authority restarts while a governed process runs (CSRG-U5). CodeSpace keeps + /// the process, its status and its workspace's lease, and the owner registers again for new + /// work. DevGuard no longer knows the scope, so the attempt stays charged as Suspect after + /// the process ends: uncertain, so never released, and never replayed: resuming its + /// approval again returns the same process. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_authority_restart_keeps_a_running_attempt_charged_and_never_replays_it() { + let Some(mut fixture) = FixtureAuthority::start(2) else { + return; + }; + let Some(helper) = launch_helper() else { + return; + }; + if !fixture.native { + return; + } + let dir = tempfile::tempdir().unwrap(); + let cli = register_cli( + &fixture.settings, + &[ + "--runner", + "in-process", + "--devguard-launch-helper", + helper.to_str().unwrap(), + ], + ); + let store = Arc::new(Store::memory().unwrap()); + let handler = CodeSpace::from_cli( + &cli, + launch_registry(dir.path()), + store.clone(), + RuntimeBackend::in_process(release(&store)), + ); + // Held for approval, granted and resumed: a process that runs until the test lets it end. + let go = dir.path().join("go"); + let script = format!( + "while [ ! -e '{}' ]; do sleep 0.05; done; echo done", + go.display() + ); + let held = handler + .exec_command(Parameters(exec_params( + "gov-confirm", + &["/bin/sh", "-c", &script], + false, + ))) + .await + .err() + .expect("held"); + let held: ErrorBody = serde_json::from_str(&held).unwrap(); + assert_eq!(held.code, ErrorCode::ApprovalRequired); + let approval_id = ApprovalId(held.approval_id.unwrap()); + handler + .approval_resolve(Parameters(codespace_domain::ApprovalResolveParams { + approval_id: approval_id.clone(), + decision: codespace_domain::ApprovalDecision::Grant, + })) + .await + .unwrap(); + let launched = resumed(&handler, &approval_id) + .await + .unwrap() + .exec_command + .expect("an exec result"); + assert_eq!(launched.dispatch_status, ExecDispatchStatus::Confirmed); + let (attempts, _) = fixture.charged(); + assert_eq!(attempts.len(), 1); + fixture.restart(); + let status = handler + .process_status(Parameters(ProcessStatusParams { + process_id: launched.process_id.clone(), + })) + .await + .unwrap() + .0; + assert_eq!(status.state, ProcessState::Running); + std::fs::write(&go, b"").unwrap(); + let (output, status) = finished(&handler, &launched.process_id).await; + assert_eq!((output.as_str(), status.exit_code), ("done\n", Some(0))); + // The process and its lease have ended; its attempt has not. Later reconciler passes + // cannot release it either. + drop( + store + .try_acquire_write("gov-confirm") + .expect("the lease is released"), + ); + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + assert_eq!( + fixture.phases(), + [(attempts[0].clone(), "suspect".to_owned())] + ); + let again = resumed(&handler, &approval_id).await.unwrap(); + assert_eq!( + again.exec_command.map(|result| result.process_id), + Some(launched.process_id) + ); + // A new execution after the restart is admitted, and released once it ends. + let fresh = executed(&handler, exec_params("gov", &["/bin/echo", "after"], false)) + .await + .unwrap(); + assert_eq!(finished(&handler, &fresh.process_id).await.0, "after\n"); + let mut charged = fixture.charged().0; + for _ in 0..100 { + if charged == attempts { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + charged = fixture.charged().0; + } + assert_eq!(charged, attempts); + } + /// D6 for the launch carriers: governed launches run concurrently with ordinary pipe and PTY /// children of the same gateway. No child but each launch's own helper may hold its permit /// carrier or transcript; no governed command holds anything beyond its standard From 7b65c7636bb9b143ed26379af8da6490bb68dcde Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 01:27:12 +0900 Subject: [PATCH 6/6] docs: lifecycle of a governed execution after its launch (CSRG-U5) The DevGuard integration roadmap gains a CSRG-U5 section, in English and Korean: - one reaper per process; - DevGuard observing a governed pipe root's scope before the reap, bounded; - PTY executions observed after Codex's reap. This is described as the interim state until the separate Codex pin change the owner chose, with the merge either including that change or asked about again; - a kill of a governed process also terminating its scope; - process control without the authority; - root exit, output EOF, handle, lease, scope end and accounting as separate events; - approvals whose execution provably never started keeping their hold; - the cases that are never replayed. The header notice and the current status name the unit, and U4's "not yet" paragraph points to the new section. The architecture page's current behaviour no longer says that three pipe paths each call try_wait. The execution contracts' approval paragraph states the hold returned on a proven non-start. Translations recorded. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- docs/devguard-integration.md | 30 +++++++++++++++++++++++++++--- docs/execution-substrate.md | 2 +- docs/ko/architecture.md | 2 +- docs/ko/devguard-integration.md | 30 +++++++++++++++++++++++++++--- docs/ko/execution-substrate.md | 2 +- docs/translations.json | 15 +++++++++------ 7 files changed, 67 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6010b7d..b6d6de0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ MCP request completion does not end a managed process. Clients continue with its > **Status: suspended as an implementation directive.** The target CS-RG structure in this section must not be implemented while the CS-RG integration-boundary revalidation is pending; the current behavior paragraph describes implemented code. No replacement architecture has been approved. The text is retained unchanged for historical traceability. -**Current behavior.** The Runner's process supervisor starts pipe commands with Tokio (`tokio::process`) and `tty: true` commands through the `codespace-pty` adapter over Codex `codex-utils-pty` at the [pinned revision](upstream-lock.md) `6b9826e3aa83b1a5947db50f4332cb9c65f1b340` (`rust-v0.154.0`). The pinned PTY spawn reaps its child internally. On the pipe path, the exit waiter, the timeout task and the kill request each call `try_wait`. +**Current behavior.** The Runner's process supervisor starts pipe commands with Tokio (`tokio::process`) and `tty: true` commands through the `codespace-pty` adapter over Codex `codex-utils-pty` at the [pinned revision](upstream-lock.md) `6b9826e3aa83b1a5947db50f4332cb9c65f1b340` (`rust-v0.154.0`). The pinned PTY spawn reaps its child internally. On the pipe path one task reaps each child; the timeout and kill requests only signal it while it is unreaped (CSRG-U5). For a governed pipe execution that task detects the exit without reaping, has DevGuard observe the scope, then reaps ([lifecycle after launch](devguard-integration.md#devguard-lifecycle)). **Target CS-RG structure.** DevGuard [design revision 1](https://github.com/novelKR/DevGuard/blob/d4981b4c241cff42687f5c2c681b583c7847776e/docs/design-revision-1.md) plans one Runner coordination layer that CodeSpace owns for every execution: execution identity, the link between approval and execution, state transitions, timeout, termination requests, output recording and cleanup coordination. Platform differences stay behind a narrow backend boundary: child creation, terminal setup, I/O wiring, exit observation and the actual reap. None of this is implemented, and the names below are design concepts, not current APIs. DevGuard client types and Codex types stay out of public MCP types. diff --git a/docs/devguard-integration.md b/docs/devguard-integration.md index 2d8cb10..05a5f59 100644 --- a/docs/devguard-integration.md +++ b/docs/devguard-integration.md @@ -2,13 +2,13 @@ # DevGuard integration roadmap -> **Status: suspended as an implementation directive.** The CS-RG work order, the planned consumer boundary and the design revision 1 references on this page must not be implemented as described; current status and prerequisites are unaffected. This is pending the CS-RG integration-boundary revalidation, an owner-directed review of the CodeSpace integration plan; it is not a work unit. No replacement architecture has been approved; the owner decides after reviewing its results. This notice suspends directives only and relaxes no safety requirement. The text below is retained unchanged for historical traceability, except the current status, the [status-only connection](#devguard-status-connection) of CSRG-U1, which the owner approved on 2026-10-02 outside this suspension, the [execution-owner registration](#devguard-owner-registration) of CSRG-U2, which the owner directed on 2026-10-03, the [admission and pre-spawn preparation](#devguard-admission) of CSRG-U3, which the owner directed on 2026-10-07, and the [managed launch](#devguard-launch) of CSRG-U4, which the owner directed on 2026-10-08. +> **Status: suspended as an implementation directive.** The CS-RG work order, the planned consumer boundary and the design revision 1 references on this page must not be implemented as described; current status and prerequisites are unaffected. This is pending the CS-RG integration-boundary revalidation, an owner-directed review of the CodeSpace integration plan; it is not a work unit. No replacement architecture has been approved; the owner decides after reviewing its results. This notice suspends directives only and relaxes no safety requirement. The text below is retained unchanged for historical traceability, except the current status, the [status-only connection](#devguard-status-connection) of CSRG-U1, which the owner approved on 2026-10-02 outside this suspension, the [execution-owner registration](#devguard-owner-registration) of CSRG-U2, which the owner directed on 2026-10-03, the [admission and pre-spawn preparation](#devguard-admission) of CSRG-U3, which the owner directed on 2026-10-07, the [managed launch](#devguard-launch) of CSRG-U4, which the owner directed on 2026-10-08, and the [lifecycle after launch](#devguard-lifecycle) of CSRG-U5, which the owner directed on 2026-10-09. [English](devguard-integration.md) | [한국어](ko/devguard-integration.md) [DevGuard](https://github.com/novelKR/DevGuard) is an independent resource authority for development workloads, licensed under Apache-2.0 like CodeSpace. Its approved integration path adds a shared admission and accounting layer while CodeSpace keeps process ownership, PTY, input/output, permissions, approval holds and workspace coordination. -**Current status:** DevGuard has completed DG-1 in its independent repository ([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [milestone ledger](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). On top of the DG-0 contracts it provides native macOS host evidence, an authenticated daemon installed as the current user's LaunchAgent, the fenced launch helper and reconciliation, the `devguard` command-line owner with Cargo adapters, parent leases for candidate tests, and upgrade and repair. Its macOS SLO is qualified for release `0.1.0-5daee5d-b3fa569e` (a release ID, not a Git tag) on the measured host and policy; Linux enforcement is not qualified. CodeSpace consumption (CS-RG) is in progress and not qualified. CSRG-U1 added an opt-in, status-only connection: a gateway built with the `devguard` feature and started with `--devguard status` reports DevGuard's status in `workspace_info`, through DevGuard's client pinned at [`6e7e065`](upstream-lock.md#devguard-client-pin). With CSRG-U2, the process that owns CodeSpace's executions registers itself with DevGuard (`--devguard register`), and a workspace's `resources` setting can require resource participation. With CSRG-U3, that owner takes a process slot and has DevGuard admit each such execution before anything is created. With CSRG-U4 and a configured launch helper (`--devguard-launch-helper`), it then launches the execution once through DevGuard's launch helper, which DevGuard authorizes after binding and policing its scope; without a helper it cancels the unstarted attempt and refuses the execution. Nothing runs without DevGuard. Observation before reaping, release and qualification are later units (CSRG-U5, U6). The qualified release remains a candidate for execution, not a selected pin; the client pin is a source pin of the client crates. +**Current status:** DevGuard has completed DG-1 in its independent repository ([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [milestone ledger](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). On top of the DG-0 contracts it provides native macOS host evidence, an authenticated daemon installed as the current user's LaunchAgent, the fenced launch helper and reconciliation, the `devguard` command-line owner with Cargo adapters, parent leases for candidate tests, and upgrade and repair. Its macOS SLO is qualified for release `0.1.0-5daee5d-b3fa569e` (a release ID, not a Git tag) on the measured host and policy; Linux enforcement is not qualified. CodeSpace consumption (CS-RG) is in progress and not qualified. CSRG-U1 added an opt-in, status-only connection: a gateway built with the `devguard` feature and started with `--devguard status` reports DevGuard's status in `workspace_info`, through DevGuard's client pinned at [`6e7e065`](upstream-lock.md#devguard-client-pin). With CSRG-U2, the process that owns CodeSpace's executions registers itself with DevGuard (`--devguard register`), and a workspace's `resources` setting can require resource participation. With CSRG-U3, that owner takes a process slot and has DevGuard admit each such execution before anything is created. With CSRG-U4 and a configured launch helper (`--devguard-launch-helper`), it then launches the execution once through DevGuard's launch helper, which DevGuard authorizes after binding and policing its scope; without a helper it cancels the unstarted attempt and refuses the execution. Nothing runs without DevGuard. With CSRG-U5, each process has one reaper; DevGuard observes a governed pipe execution's scope before CodeSpace reaps its root, and for now a PTY execution's only after the pinned Codex PTY reaps it; killing a governed process also terminates its scope; and an approval whose governed execution provably never started can be resumed again. Qualification is a later unit (CSRG-U6). The qualified release remains a candidate for execution, not a selected pin; the client pin is a source pin of the client crates. DevGuard [design revision 1](https://github.com/novelKR/DevGuard/blob/d4981b4c241cff42687f5c2c681b583c7847776e/docs/design-revision-1.md), merged in [DevGuard PR #8](https://github.com/novelKR/DevGuard/pull/8) as `d4981b4c241cff42687f5c2c681b583c7847776e`, revised the CS-RG execution layer and work order. It changes plans and design decisions that bind later implementation. It does not change current CodeSpace behavior or any implementation or qualification status. @@ -226,7 +226,31 @@ Tests check each with controls: a deliberately inheritable descriptor; stand-in **Supported.** macOS with a DevGuard service that has native host evidence: `in-process`, and `uds` with `--runtime-bin`, each on a pipe and on a PTY. Unsupported: `uds` with `--runner-socket` and Linux before DG-LINUX, where the owner cannot register; the Linux command sandbox and `Enabled` network, which are refused before anything is committed. -**Not yet (CSRG-U5).** CodeSpace reaps and terminates the process as before, without asking DevGuard to observe or signal the scope; on a pipe, `terminate_process` and a timeout kill the root only. DevGuard releases a scope whose members all ended with its root. A descendant that outlives the root keeps the attempt charged while it runs; one that CodeSpace's reap leaves before DevGuard observed it leaves the attempt suspect. Approval dispatch states, release on every termination path and qualification are not claimed. +**Lifecycle (CSRG-U5).** Reaping, observation before the reap, termination of the scope, approvals and the cases that are never replayed are in [lifecycle after launch](#devguard-lifecycle). + + + +## Lifecycle after launch (CSRG-U5) + +CSRG-U5 completes a governed execution's lifecycle after its launch: who reaps it, when DevGuard observes its scope, how a kill reaches the scope, what CodeSpace controls without DevGuard, and when an approval can be used again. + +**One reaper.** A pipe child has one reaper: the task that waits for it. A timeout, `terminate_process`, workspace termination and the settlement of a failed launch record their intent and signal the child only while it is unreaped; they never wait for it. At shutdown the reaper goes with the runtime: a running child is killed, and tokio's orphan queue reaps it, once. A PTY child is reaped by the pinned Codex PTY's own waiter, as before. + +**Observation before the reap.** For a governed pipe execution, the reaper detects the root's exit without reaping it (`waitid` with `WNOWAIT`) and has DevGuard `Observe` the scope while the exited root still holds its PID. It then reaps the root, records its exit, and has DevGuard observe once more. A descendant left in the scope therefore stays the scope's: the attempt stays charged while it runs, and DevGuard releases it once it ends. Each observation is bounded (2 s), so the one before the reap delays the exit status by at most that. One that times out or fails is skipped and the reap goes ahead: an observation never adds a reaper or keeps a child unreaped. An ungoverned execution is not observed. + +**PTY, for now.** The pinned Codex PTY reaps in its own waiter, with no hook before the reap, so a governed PTY execution is observed only after the reap. Descendants that DevGuard's reconciler already saw while the root held its PID stay tracked after Codex reaps. Tracking is lost only for a descendant that appears, and whose root exits, before any observation sees it; its attempt then stays suspect and charged until a reboot. This is an interim state, not a final limit: observation before the reap for PTY executions is to come from a separate Codex pin change, which the owner chose on 2026-10-11. CSRG-U5 is merged with it if that change has merged first; otherwise the owner is asked again before the merge. + +**Termination.** When CodeSpace kills a governed process (`terminate_process`, workspace termination or a timeout), it also asks DevGuard to `Terminate` the scope: SIGKILL to every identity DevGuard can recheck, members and known escapes. The request runs on its own thread and is never awaited, so CodeSpace's own kill never waits for DevGuard. A sent signal is not a release: DevGuard releases the attempt once it observes the scope's end. + +**Without the authority.** Once a governed process runs, `process_status`, `read_process`, `write_stdin`, `process_resize`, `terminate_process` and the timeout never ask DevGuard. A stalled or stopped authority costs at most the bounded observations; new governed executions are refused as before. + +**Separate events.** Root exit, output EOF, removal of the process handle, release of the workspace lease, the end of DevGuard's scope and the release of its accounting are separate events. A descendant that keeps the output open delays the EOF and the lease, not the root's exit status. The attempt stays charged until DevGuard observes the scope's end, whatever CodeSpace's own state. + +**Approvals.** A resumed approval whose governed execution is refused with proof that the command never started gets its hold back (`granted` again) and can be resumed again, as a new attempt. The proof is DevGuard's state of the attempt: never asked, not recorded, denied, cancelled, expired, or released on the owner's report that it holds no helper. Every other outcome consumes the approval with its result as before, including an unknown dispatch, a launched, suspect or scope-ended attempt, a lost answer and a missing process handle. Resuming a consumed approval returns its stored result and launches nothing. + +**No replay.** Without proof that the first attempt never started, nothing is started again: not after a transcript that does not settle in time, a lost or unknown `BeginLaunch` answer, a worker lost after it took a launch, a cancelled request, a root reaped before any observation, an authority restart or a repeated resume. After an authority restart DevGuard no longer knows a running scope: the process stays CodeSpace's, the owner registers again for new work, and the attempt stays charged as suspect after the process ends. + +**Supported** as for CSRG-U4. **Not claimed:** CS-RG qualification (CSRG-U6), observation before the reap for PTY executions (above), and Linux before DG-LINUX. ## CS-RG work order diff --git a/docs/execution-substrate.md b/docs/execution-substrate.md index 7adc3c6..b48952f 100644 --- a/docs/execution-substrate.md +++ b/docs/execution-substrate.md @@ -70,7 +70,7 @@ The Runner may start a recursive filesystem watcher for a workspace on the first `approval_create`, `approval_resolve`, and `operation_resume` are callable. They hold a mutation the workspace profile already allows until the hold is granted. This is not a security boundary: they do not escalate permissions, apply `{ "network": true }` or `ClientClaims.approved`, or write V4A snapshots into the patch operations ledger. The same MCP caller can grant. -When the operator sets workspace `approvals` to `confirm`, a policy-allowed `apply_patch` or `exec_command` returns `APPROVAL_REQUIRED` with an `approval_id` before `begin()` or spawn. Retrying the same logical request reuses that active hold. `off` (the default) still runs those tools immediately; the three tools remain listed so an explicit `approval_create` can open a hold. Grant does not change the profile. Resume claims `granted` into `queued` (restart-safe; no side effect yet), re-checks `allow()`, waits for the resource, then moves to `resuming` only when dispatch can start. `consumed` is recorded only together with the terminal result. A later resume from `queued` reacquires. A later resume from `resuming` returns a stored result, recovers a patch from the operations ledger, or returns `APPROVAL_AMBIGUOUS`. Interrupted exec is not respawned. A denied policy stays `UNAUTHORIZED`. Exec remains on `process_id`. v1 does not authenticate host versus model. The server guarantees the policy re-check on resume plus that durability contract. +When the operator sets workspace `approvals` to `confirm`, a policy-allowed `apply_patch` or `exec_command` returns `APPROVAL_REQUIRED` with an `approval_id` before `begin()` or spawn. Retrying the same logical request reuses that active hold. `off` (the default) still runs those tools immediately; the three tools remain listed so an explicit `approval_create` can open a hold. Grant does not change the profile. Resume claims `granted` into `queued` (restart-safe; no side effect yet), re-checks `allow()`, waits for the resource, then moves to `resuming` only when dispatch can start. `consumed` is recorded only together with the terminal result. A resumed `exec_command` whose governed execution the resource authority refuses with proof that it never started goes back from `resuming` to `granted` instead and can be resumed again ([CSRG-U5](devguard-integration.md#devguard-lifecycle)). A later resume from `queued` reacquires. A later resume from `resuming` returns a stored result, recovers a patch from the operations ledger, or returns `APPROVAL_AMBIGUOUS`. Interrupted exec is not respawned. A denied policy stays `UNAUTHORIZED`. Exec remains on `process_id`. v1 does not authenticate host versus model. The server guarantees the policy re-check on resume plus that durability contract. ## Planned managed-execution contracts diff --git a/docs/ko/architecture.md b/docs/ko/architecture.md index 46f6bf8..532b624 100644 --- a/docs/ko/architecture.md +++ b/docs/ko/architecture.md @@ -64,7 +64,7 @@ MCP 요청이 끝나도 관리 중인 프로세스는 유지됩니다. 클라이 > **상태: 구현 지시로서 효력 중지.** CS-RG 통합 경계 재검증이 끝날 때까지 이 절의 목표 CS-RG 구조를 구현하지 않습니다. 현재 동작 문단은 구현된 코드를 설명합니다. 대체 구조는 승인되지 않았습니다. 내용은 이력 추적을 위해 바꾸지 않고 남깁니다. -**현재 동작.** Runner의 프로세스 관리는 파이프 명령을 Tokio(`tokio::process`)로 시작하고, `tty: true` 명령은 [고정 버전](upstream-lock.md) `6b9826e3aa83b1a5947db50f4332cb9c65f1b340`(`rust-v0.154.0`)의 Codex `codex-utils-pty` 위에 있는 `codespace-pty` 어댑터로 시작합니다. 고정 버전의 PTY spawn은 자식 프로세스를 내부에서 reap합니다. 파이프 경로에서는 종료 대기, 제한 시간 작업, 종료 요청이 각각 `try_wait`를 호출합니다. +**현재 동작.** Runner의 프로세스 관리는 파이프 명령을 Tokio(`tokio::process`)로 시작하고, `tty: true` 명령은 [고정 버전](upstream-lock.md) `6b9826e3aa83b1a5947db50f4332cb9c65f1b340`(`rust-v0.154.0`)의 Codex `codex-utils-pty` 위에 있는 `codespace-pty` 어댑터로 시작합니다. 고정 버전의 PTY spawn은 자식 프로세스를 내부에서 reap합니다. 파이프 경로에서는 자식마다 작업 하나가 reap하고, 제한 시간과 종료 요청은 자식이 아직 reap되지 않았을 때 signal만 보냅니다(CSRG-U5). 관리 대상 파이프 실행에서는 그 작업이 reap하지 않고 종료를 감지해 DevGuard에 scope를 관찰하게 한 뒤 reap합니다([실행 뒤 생명주기](devguard-integration.md#devguard-lifecycle)). **CS-RG 목표 구조.** DevGuard [설계 개정 1](https://github.com/novelKR/DevGuard/blob/d4981b4c241cff42687f5c2c681b583c7847776e/docs/ko/design-revision-1.md)은 모든 실행에 대해 CodeSpace가 소유하는 Runner 조정 계층 하나를 계획합니다. 이 계층은 실행 식별자, 승인과 실행의 연결, 상태 전이, 제한 시간, 종료 요청, 출력 기록, 정리 조정을 담당합니다. 플랫폼별 차이인 자식 프로세스 생성, 터미널 설정, 입출력 연결, 종료 관측, 실제 reap은 좁은 백엔드 경계 뒤에 둡니다. 이 구조는 구현되지 않았으며 아래 이름은 현재 API가 아니라 설계 개념입니다. DevGuard client 타입과 Codex 타입은 공개 MCP 타입에 들어가지 않습니다. diff --git a/docs/ko/devguard-integration.md b/docs/ko/devguard-integration.md index 3eaec63..659748a 100644 --- a/docs/ko/devguard-integration.md +++ b/docs/ko/devguard-integration.md @@ -2,13 +2,13 @@ # DevGuard 결합 로드맵 -> **상태: 구현 지시로서 효력 중지.** 이 페이지의 ‘CS-RG 작업 순서’, ‘소비 경계’, ‘설계 개정 1 참조’ 절은 적힌 대로 구현하지 않습니다. 현재 상태와 선행 조건은 영향이 없습니다. CodeSpace 통합 계획에 대해 소유자가 지시한 검토인 CS-RG 통합 경계 재검증(작업 단위가 아닙니다)이 끝날 때까지 적용됩니다. 대체 구조는 승인되지 않았으며, 소유자가 재검증 결과를 검토한 뒤 결정합니다. 이 표기는 지시의 효력만 중지하며 어떤 안전 요구도 완화하지 않습니다. 아래 내용은 이력 추적을 위해 바꾸지 않고 남깁니다. 다만 현재 상태, 소유자가 2026-10-02에 이 효력 중지와 별도로 승인한 CSRG-U1의 [상태 전용 연결](#devguard-status-connection), 소유자가 2026-10-03에 지시한 CSRG-U2의 [실행 소유자 등록](#devguard-owner-registration), 소유자가 2026-10-07에 지시한 CSRG-U3의 [실행 허용과 spawn 전 준비](#devguard-admission), 소유자가 2026-10-08에 지시한 CSRG-U4의 [관리형 실행](#devguard-launch)은 예외입니다. +> **상태: 구현 지시로서 효력 중지.** 이 페이지의 ‘CS-RG 작업 순서’, ‘소비 경계’, ‘설계 개정 1 참조’ 절은 적힌 대로 구현하지 않습니다. 현재 상태와 선행 조건은 영향이 없습니다. CodeSpace 통합 계획에 대해 소유자가 지시한 검토인 CS-RG 통합 경계 재검증(작업 단위가 아닙니다)이 끝날 때까지 적용됩니다. 대체 구조는 승인되지 않았으며, 소유자가 재검증 결과를 검토한 뒤 결정합니다. 이 표기는 지시의 효력만 중지하며 어떤 안전 요구도 완화하지 않습니다. 아래 내용은 이력 추적을 위해 바꾸지 않고 남깁니다. 다만 현재 상태, 소유자가 2026-10-02에 이 효력 중지와 별도로 승인한 CSRG-U1의 [상태 전용 연결](#devguard-status-connection), 소유자가 2026-10-03에 지시한 CSRG-U2의 [실행 소유자 등록](#devguard-owner-registration), 소유자가 2026-10-07에 지시한 CSRG-U3의 [실행 허용과 spawn 전 준비](#devguard-admission), 소유자가 2026-10-08에 지시한 CSRG-U4의 [관리형 실행](#devguard-launch), 소유자가 2026-10-09에 지시한 CSRG-U5의 [실행 뒤 생명주기](#devguard-lifecycle)는 예외입니다. [English](../devguard-integration.md) | [한국어](devguard-integration.md) [DevGuard](https://github.com/novelKR/DevGuard)는 개발 작업의 자원을 중앙에서 관리하는 독립 시스템이며 CodeSpace와 동일한 Apache-2.0 라이선스를 적용한다. 승인된 결합 경로에 따라 실행 허용과 자원 회계를 공통 계층에 맡기고, CodeSpace는 프로세스 소유권, PTY, 입출력, 권한, 승인 hold와 workspace 조정을 계속 담당한다. -**현재 상태:** DevGuard는 독립 저장소에서 DG-1을 완료했다([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [마일스톤 원장](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). DG-0 계약 위에 macOS 실제 호스트 증거, 현재 사용자의 LaunchAgent로 설치하는 인증 daemon, fenced launch helper와 대조, Cargo adapter를 갖춘 `devguard` 명령행 owner, 후보 시험용 parent lease, upgrade와 repair를 제공한다. 측정한 호스트와 정책에서 release `0.1.0-5daee5d-b3fa569e`(Git tag가 아닌 release ID)의 macOS SLO qualification을 마쳤으며 Linux 강제 보호는 qualification하지 않았다. CodeSpace 소비(CS-RG)는 진행 중이며 qualification을 마치지 않았다. CSRG-U1은 opt-in 상태 전용 연결을 추가했다. `devguard` feature로 빌드하고 `--devguard status`로 시작한 gateway는 [`6e7e065`](upstream-lock.md#devguard-client-pin)에 고정한 DevGuard client로 DevGuard의 상태를 `workspace_info`에 보고한다. CSRG-U2부터는 CodeSpace의 실행을 소유한 프로세스가 스스로를 DevGuard에 등록하며(`--devguard register`), workspace의 `resources` 설정으로 자원 참여를 필수로 지정할 수 있다. CSRG-U3부터 그 소유자는 이런 실행마다 무엇을 만들기 전에 프로세스 slot을 잡고 DevGuard의 실행 허용을 받는다. CSRG-U4부터는 launch helper를 설정했으면(`--devguard-launch-helper`) 그 실행을 DevGuard의 launch helper로 한 번 실행한다. DevGuard는 helper의 scope를 묶고 정책을 적용한 뒤에 실행을 승인한다. helper가 없으면 시작하지 않은 attempt를 취소하고 실행을 거부한다. DevGuard 없이 실행하는 것은 없다. reap 전 관찰, 해제, qualification은 이후 단위(CSRG-U5, U6)다. qualification을 마친 release는 실행에 쓸 후보로 남으며 선택된 pin이 아니다. client pin은 client crate의 소스 pin이다. +**현재 상태:** DevGuard는 독립 저장소에서 DG-1을 완료했다([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [마일스톤 원장](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). DG-0 계약 위에 macOS 실제 호스트 증거, 현재 사용자의 LaunchAgent로 설치하는 인증 daemon, fenced launch helper와 대조, Cargo adapter를 갖춘 `devguard` 명령행 owner, 후보 시험용 parent lease, upgrade와 repair를 제공한다. 측정한 호스트와 정책에서 release `0.1.0-5daee5d-b3fa569e`(Git tag가 아닌 release ID)의 macOS SLO qualification을 마쳤으며 Linux 강제 보호는 qualification하지 않았다. CodeSpace 소비(CS-RG)는 진행 중이며 qualification을 마치지 않았다. CSRG-U1은 opt-in 상태 전용 연결을 추가했다. `devguard` feature로 빌드하고 `--devguard status`로 시작한 gateway는 [`6e7e065`](upstream-lock.md#devguard-client-pin)에 고정한 DevGuard client로 DevGuard의 상태를 `workspace_info`에 보고한다. CSRG-U2부터는 CodeSpace의 실행을 소유한 프로세스가 스스로를 DevGuard에 등록하며(`--devguard register`), workspace의 `resources` 설정으로 자원 참여를 필수로 지정할 수 있다. CSRG-U3부터 그 소유자는 이런 실행마다 무엇을 만들기 전에 프로세스 slot을 잡고 DevGuard의 실행 허용을 받는다. CSRG-U4부터는 launch helper를 설정했으면(`--devguard-launch-helper`) 그 실행을 DevGuard의 launch helper로 한 번 실행한다. DevGuard는 helper의 scope를 묶고 정책을 적용한 뒤에 실행을 승인한다. helper가 없으면 시작하지 않은 attempt를 취소하고 실행을 거부한다. DevGuard 없이 실행하는 것은 없다. CSRG-U5부터 프로세스마다 reaper는 하나다. DevGuard는 관리 대상 pipe 실행의 scope를 CodeSpace가 root를 reap하기 전에 관찰하고, PTY 실행은 당분간 고정된 Codex PTY가 reap한 뒤에만 관찰한다. 관리 대상 프로세스를 죽이면 그 scope도 종료하며, 시작하지 않았음이 증명된 관리 대상 실행의 승인은 다시 재개할 수 있다. qualification은 이후 단위(CSRG-U6)다. qualification을 마친 release는 실행에 쓸 후보로 남으며 선택된 pin이 아니다. client pin은 client crate의 소스 pin이다. DevGuard [설계 개정 1](https://github.com/novelKR/DevGuard/blob/d4981b4c241cff42687f5c2c681b583c7847776e/docs/ko/design-revision-1.md)은 [DevGuard PR #8](https://github.com/novelKR/DevGuard/pull/8)의 병합 `d4981b4c241cff42687f5c2c681b583c7847776e`로 반영되었으며 CS-RG의 실행 계층과 작업 순서를 개정했다. 이 개정은 계획과 이후 구현을 구속하는 설계 결정을 바꾼다. 현재 CodeSpace 동작이나 구현·qualification 상태는 바꾸지 않는다. @@ -226,7 +226,31 @@ DevGuard는 helper의 부모가 등록된 소유자이기를 요구하므로 실 **지원.** 실제 호스트 증거를 가진 DevGuard 서비스가 있는 macOS에서 `in-process`와 `--runtime-bin`을 쓴 `uds`를 각각 pipe와 PTY로 지원한다. 지원하지 않는 것: 소유자가 등록할 수 없는 `--runner-socket`을 쓴 `uds`와 DG-LINUX 전의 Linux, 무엇을 확정하기 전에 거부되는 Linux 명령 sandbox와 `Enabled` network. -**아직 하지 않는 일(CSRG-U5).** CodeSpace는 DevGuard에 scope를 관찰하거나 signal하게 하지 않고 이전처럼 프로세스를 reap하고 종료한다. pipe에서 `terminate_process`와 timeout은 root만 죽인다. DevGuard는 모든 구성원이 root와 함께 끝난 scope를 해제한다. root보다 오래 사는 자손은 실행되는 동안 attempt를 계속 차지하게 하고, DevGuard가 관찰하기 전에 CodeSpace의 reap 뒤에 남은 자손은 attempt를 suspect로 남긴다. 승인 dispatch 상태, 모든 종료 경로의 해제, qualification은 주장하지 않는다. +**생명주기(CSRG-U5).** reap, reap 전 관찰, scope 종료, 승인, 다시 실행하지 않는 경우는 [실행 뒤 생명주기](#devguard-lifecycle)에 있다. + + + +## 실행 뒤 생명주기(CSRG-U5) + +CSRG-U5는 관리 대상 실행의 실행 뒤 생명주기를 완성한다. 누가 reap하는지, DevGuard가 언제 scope를 관찰하는지, kill이 scope에 어떻게 닿는지, DevGuard 없이 CodeSpace가 무엇을 제어하는지, 승인을 언제 다시 쓸 수 있는지를 정한다. + +**reaper 하나.** pipe 자식의 reaper는 그 자식을 기다리는 task 하나다. timeout, `terminate_process`, workspace 종료, 실패한 실행의 정리는 의도를 기록하고 자식이 아직 reap되지 않았을 때만 signal을 보내며, 자식을 기다리지 않는다. 종료 시 reaper는 runtime과 함께 사라진다. 실행 중인 자식은 죽고, tokio의 orphan queue가 한 번 reap한다. PTY 자식은 이전처럼 고정된 Codex PTY의 waiter가 reap한다. + +**reap 전 관찰.** 관리 대상 pipe 실행에서 reaper는 root의 종료를 reap하지 않고 감지하며(`WNOWAIT`를 준 `waitid`), 종료한 root가 아직 PID를 갖고 있는 동안 DevGuard에 scope를 `Observe`하게 한다. 그다음 root를 reap하고 종료를 기록한 뒤 DevGuard에 한 번 더 관찰하게 한다. 그래서 scope에 남은 자손은 계속 그 scope의 것이다. 자손이 실행되는 동안 attempt는 계속 자원을 차지하고, 자손이 끝나면 DevGuard가 해제한다. 관찰마다 상한(2초)이 있으므로 reap 전 관찰이 종료 상태를 늦추는 시간도 그 이하다. 시간이 지나거나 실패한 관찰은 건너뛰고 reap을 진행한다. 관찰은 reaper를 늘리지 않고 자식을 reap하지 않은 채로 두지도 않는다. 관리하지 않는 실행은 관찰하지 않는다. + +**당분간의 PTY.** 고정된 Codex PTY는 자신의 waiter에서 reap하며 reap 전에 끼어들 hook이 없다. 그래서 관리 대상 PTY 실행은 reap한 뒤에만 관찰한다. root가 PID를 갖고 있는 동안 DevGuard의 reconciler가 이미 본 자손은 Codex가 reap한 뒤에도 계속 추적된다. 추적을 잃는 것은 어떤 관찰도 보기 전에 나타났고, 그 root도 그 전에 종료한 자손뿐이다. 그 attempt는 재부팅 때까지 suspect로 자원을 차지한다. 이는 최종 한계가 아닌 임시 상태다. PTY 실행의 reap 전 관찰은 소유자가 2026-10-11에 고른 별도의 Codex pin 변경으로 들어올 예정이다. 그 변경이 먼저 병합되면 CSRG-U5는 그것과 함께 병합하고, 그렇지 않으면 병합 전에 소유자에게 다시 묻는다. + +**종료.** CodeSpace가 관리 대상 프로세스를 죽이면(`terminate_process`, workspace 종료, timeout) DevGuard에도 scope를 `Terminate`하게 한다. DevGuard가 다시 확인할 수 있는 모든 identity, 즉 구성원과 알려진 이탈 프로세스에 SIGKILL을 보낸다. 이 요청은 자신의 thread에서 돌고 기다리지 않으므로 CodeSpace 자신의 kill은 DevGuard를 기다리지 않는다. signal을 보낸 것은 해제가 아니다. DevGuard는 scope의 끝을 관찰한 뒤에 attempt를 해제한다. + +**authority 없이.** 관리 대상 프로세스가 실행된 뒤에는 `process_status`, `read_process`, `write_stdin`, `process_resize`, `terminate_process`, timeout이 DevGuard에 묻지 않는다. 멈추거나 내려간 authority가 더하는 비용은 상한 있는 관찰뿐이다. 새 관리 대상 실행은 이전처럼 거부된다. + +**별개의 사건.** root 종료, 출력 EOF, 프로세스 handle 제거, workspace lease 해제, DevGuard scope의 끝, 그 회계의 해제는 별개의 사건이다. 출력을 열어 둔 자손은 EOF와 lease를 늦추지만 root의 종료 상태는 늦추지 않는다. attempt는 CodeSpace 자신의 상태와 관계없이 DevGuard가 scope의 끝을 관찰할 때까지 자원을 차지한다. + +**승인.** 재개한 승인의 관리 대상 실행이 명령을 시작하지 않았다는 증거와 함께 거부되면 승인은 홀드를 돌려받아(다시 `granted`) 새 attempt로 다시 재개할 수 있다. 증거는 DevGuard가 가진 attempt의 상태다. 묻지 않음, 기록되지 않음, 거부됨, 취소됨, 만료됨, 또는 소유자가 helper를 갖고 있지 않다고 보고해 해제됨이다. 그 밖의 결과는 모두 이전처럼 결과와 함께 승인을 소비한다. 알 수 없는 dispatch, 실행되었거나 suspect이거나 scope가 끝난 attempt, 잃어버린 답, 없는 프로세스 handle도 그렇다. 소비된 승인을 재개하면 저장된 결과를 돌려주며 아무것도 실행하지 않는다. + +**재실행 없음.** 첫 attempt가 시작하지 않았다는 증거가 없으면 아무것도 다시 시작하지 않는다. 제때 끝나지 않은 transcript, 잃어버렸거나 알 수 없는 `BeginLaunch` 답, 실행 요청을 받은 뒤 잃은 worker, 취소된 요청, 관찰 전에 reap된 root, authority 재시작, 반복된 재개 모두 그렇다. authority가 재시작하면 DevGuard는 실행 중인 scope를 더는 알지 못한다. 프로세스는 계속 CodeSpace의 것이고, 소유자는 새 작업을 위해 다시 등록하며, attempt는 프로세스가 끝난 뒤에도 suspect로 자원을 차지한다. + +**지원**은 CSRG-U4와 같다. **주장하지 않는 것:** CS-RG qualification(CSRG-U6), PTY 실행의 reap 전 관찰(위), DG-LINUX 전의 Linux. ## CS-RG 작업 순서 diff --git a/docs/ko/execution-substrate.md b/docs/ko/execution-substrate.md index f519e4b..f933426 100644 --- a/docs/ko/execution-substrate.md +++ b/docs/ko/execution-substrate.md @@ -79,7 +79,7 @@ Runner는 해당 작업 공간에서 처음 `read`·`find`·`version`·`apply_pa `approval_create`, `approval_resolve`, `operation_resume`을 호출할 수 있습니다. 작업 공간 프로필이 이미 허용한 변경을 홀드가 승인될 때까지 멈춥니다. 보안 경계가 아닙니다. 권한을 높이거나 `{ "network": true }`·`ClientClaims.approved`를 적용하거나 패치 원장에 V4A 스냅샷을 넣지 않습니다. 같은 MCP 호출자가 grant할 수 있습니다. -운영자가 작업 공간 `approvals`를 `confirm`으로 두면, 정책이 허용한 `apply_patch`와 `exec_command`는 `begin()`이나 프로세스 시작 전에 `APPROVAL_REQUIRED`와 `approval_id`를 반환합니다. 같은 논리 요청을 다시 보내면 그 활성 홀드를 재사용합니다. 기본값 `off`에서는 해당 도구가 바로 실행됩니다. 세 도구는 목록에 남아 있으므로 명시적 `approval_create`로 홀드를 만들 수 있습니다. grant는 프로필을 바꾸지 않습니다. 재개는 `granted`를 `queued`로 옮긴 뒤 `allow()`를 다시 검사하고, 자원을 받은 다음에야 `resuming`으로 올립니다. `consumed`는 단말 결과와 함께만 기록됩니다. `queued`에서 재시작하면 다시 acquire합니다. `resuming`에서 이후 재개는 그 결과, 패치 원장 복구, 또는 `APPROVAL_AMBIGUOUS`를 반환합니다. 중단된 exec는 다시 spawn하지 않습니다. 정책 거절은 그대로 `UNAUTHORIZED`입니다. 명령은 `process_id`로 다룹니다. v1은 호스트와 모델을 구분하지 않습니다. 서버가 보장하는 것은 재개 시 정책 재검사와 이 내구성 계약입니다. +운영자가 작업 공간 `approvals`를 `confirm`으로 두면, 정책이 허용한 `apply_patch`와 `exec_command`는 `begin()`이나 프로세스 시작 전에 `APPROVAL_REQUIRED`와 `approval_id`를 반환합니다. 같은 논리 요청을 다시 보내면 그 활성 홀드를 재사용합니다. 기본값 `off`에서는 해당 도구가 바로 실행됩니다. 세 도구는 목록에 남아 있으므로 명시적 `approval_create`로 홀드를 만들 수 있습니다. grant는 프로필을 바꾸지 않습니다. 재개는 `granted`를 `queued`로 옮긴 뒤 `allow()`를 다시 검사하고, 자원을 받은 다음에야 `resuming`으로 올립니다. `consumed`는 단말 결과와 함께만 기록됩니다. 재개한 `exec_command`의 관리 대상 실행을 자원 authority가 시작하지 않았다는 증거와 함께 거부하면, 대신 `resuming`에서 `granted`로 돌아가 다시 재개할 수 있습니다([CSRG-U5](devguard-integration.md#devguard-lifecycle)). `queued`에서 재시작하면 다시 acquire합니다. `resuming`에서 이후 재개는 그 결과, 패치 원장 복구, 또는 `APPROVAL_AMBIGUOUS`를 반환합니다. 중단된 exec는 다시 spawn하지 않습니다. 정책 거절은 그대로 `UNAUTHORIZED`입니다. 명령은 `process_id`로 다룹니다. v1은 호스트와 모델을 구분하지 않습니다. 서버가 보장하는 것은 재개 시 정책 재검사와 이 내구성 계약입니다. ## 계획된 관리 실행 계약 diff --git a/docs/translations.json b/docs/translations.json index 2dea601..47170e4 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -46,11 +46,13 @@ "devguard-integration", "devguard-integration-roadmap", "devguard-launch", + "devguard-lifecycle", "devguard-owner-registration", "devguard-status-connection", "devguard-결합-로드맵", "execution-owner-registration-csrg-u2", "initial-gateway-recovery-scope", + "lifecycle-after-launch-csrg-u5", "managed-launch-csrg-u4", "minimum-adoption-conditions", "plan-and-evidence-references", @@ -67,13 +69,14 @@ "선행-조건과-우선순위", "설계-개정-1-참조", "소비-경계", + "실행-뒤-생명주기csrg-u5", "실행-소유자-등록csrg-u2", "실행-허용과-spawn-전-준비csrg-u3", "최소-도입-조건", "최초-gateway-복구-범위" ], - "source_sha256": "945abaa05fb2da9ff17045670fabc83d0e4a20719281c9b5815dc40354198e73", - "translation_sha256": "c7f9aba3d8ffb3876a7b5a196dd327c6f6d8a114df49d99e9c449b56d6640b41" + "source_sha256": "7c67aaf956dd4ef71abd7d3f778b2aaf40792efaed55c9fef8bdacbe11f5e6d7", + "translation_sha256": "7af753ee9c0b54ac17753db23dea09fbafcb7a1c52a3f83f48286228cd2dfe21" }, { "id": "overview", @@ -288,8 +291,8 @@ "현재-실행-구조", "확장-시-유지할-경계" ], - "source_sha256": "15fb1388e62eb8dadc7fc92cdc23e1d6ad19ddf11b31a0ba1c68480a912e846d", - "translation_sha256": "ae5c9419d4147e29c40e57d55d1b8ac19747ded5afe4884eedf7bb3442a142eb" + "source_sha256": "524cd7d63660e54377bd3916b461cf1d3efa92308feadaab6ce332cde4b68625", + "translation_sha256": "5e88314d135b2184fe06255ec1fd323f972c7475e33bd2f4fd6d9fc503a65cf2" }, { "id": "execution-substrate", @@ -353,8 +356,8 @@ "확인-홀드", "훅과-스킬" ], - "source_sha256": "3b7d6bb20ebda7f707e31f366c7722d4bd3dc9a4506efd3172b3c10625926a41", - "translation_sha256": "d2826b3d8be52425302bab7ba47659da79fa1f56f13f553675c4d4670e206d26" + "source_sha256": "be934715cd35e1d90c81bbe881681a889592de3a28ff56455b9e448b997bf2e5", + "translation_sha256": "b307eb918b5be50b4a7ec79f78d1ce1dd7d1a05a0ff81fc6f2c21fe382b571f1" }, { "id": "protocol-compatibility",