From dc6bdbcc1104130ee313865b0fedca75b1df21e4 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 09:05:21 +0900 Subject: [PATCH 1/3] pty, runner: observe a governed PTY root before its reap (CSRG-U5) The pinned Codex PTY reaps a PTY child in its own waiter at once, so U5 observed a governed PTY execution only after the reap. A descendant that appeared, with its root exiting, before any observation saw it was then lost to DevGuard's tracking. The Codex patch recorded in #93 lets that waiter offer an observer one bounded window while the exited child is still unreaped. - codespace-pty: spawn_observed, with ExitWindow (release it, or drop it to release the child at once), ExitWatch and WindowEnd. Codex types stay inside the adapter. - runner: a PTY process with an exit observer, that is a governed execution, is spawned observed, with OBSERVE_BOUND as the window's bound. - When the window comes, the reaper's observer runs as for a pipe root (BeforeReap, bounded); AfterReap follows the reap as before. - While a PTY root waits in its window, termination and the timeout leave it to its reaper, as for a pipe root, so how it ended is reported unchanged. - Processes without an observer spawn as before. - Tests: - adapter, on both Unix paths: the window comes while the child is unreaped; an unreleased window is reaped at its bound and refuses a late release; a dropped window reaps at once. - runner, PTY: the observer sees the root unreaped, then reaped; a stalled observer holds it at most the bound; a failing observer releases it at once (pipe and PTY); termination and the timeout in the window keep its result and call no kill hook; output drains in full in the window. - The governed launch test now expects two observations on a PTY. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/pty/src/lib.rs | 234 ++++++++++++++++++++++++++++++- crates/runner/src/launch.rs | 6 +- crates/runner/src/process.rs | 265 ++++++++++++++++++++++++++++++++--- 3 files changed, 477 insertions(+), 28 deletions(-) diff --git a/crates/pty/src/lib.rs b/crates/pty/src/lib.rs index c831efe..4644f1c 100644 --- a/crates/pty/src/lib.rs +++ b/crates/pty/src/lib.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::os::fd::RawFd; use std::path::Path; +use std::time::Duration; use tokio::sync::{mpsc, oneshot}; @@ -99,11 +100,116 @@ pub async fn spawn_inheriting( ) .await .map_err(|err| err.to_string())?; - Ok(PtySession { + Ok(session(spawned)) +} + +fn session(spawned: codex_utils_pty::SpawnedProcess) -> PtySession { + PtySession { handle: spawned.session, stdout_rx: Some(spawned.stdout_rx), exit_rx: Some(spawned.exit_rx), - }) + } +} + +/// One chance to look at an observed spawn's exited child before it is reaped (CSRG-U5). Until +/// it is released, or the spawn's bound passes, the child is a zombie that keeps its process ID +/// and process group ID. Dropping it releases the child at once. +pub struct ExitWindow(codex_utils_pty::observe::ExitOpportunity); + +impl ExitWindow { + /// Let the child be reaped now. Refused once the bound has passed, when it was reaped. + pub fn release(self) -> Result<(), WindowExpired> { + self.0.acknowledge().map_err(|_| WindowExpired) + } +} + +/// An [`ExitWindow`] released after its bound had passed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowExpired; + +/// How an observed spawn's window ended, reported once the child has been reaped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowEnd { + /// Released before the bound. + Released, + /// The bound passed first. + Expired, + /// Dropped without a release, or nobody took it. + Abandoned, + /// The exit could not be seen without reaping, so no window was offered. + Unavailable, +} + +/// The observer's side of an observed spawn. +pub struct ExitWatch { + window: Option>, + end: oneshot::Receiver, +} + +impl ExitWatch { + /// The window, once the child has exited and before it is reaped; `None` when none is + /// offered, and after it was taken. + pub async fn window(&mut self) -> Option { + let window = self.window.take()?; + window.await.ok().map(ExitWindow) + } + + /// How the window ended, once the child has been reaped; `None` when the waiter went away + /// without saying. + pub async fn end(self) -> Option { + use codex_utils_pty::observe::ObservationOutcome; + Some(match self.end.await.ok()? { + ObservationOutcome::Completed => WindowEnd::Released, + ObservationOutcome::TimedOut => WindowEnd::Expired, + ObservationOutcome::Failed => WindowEnd::Abandoned, + ObservationOutcome::Unavailable => WindowEnd::Unavailable, + }) + } +} + +/// [`spawn_inheriting`] with an exit window (CSRG-U5). Once the child exits, its one waiter, the +/// pinned Codex PTY's with the recorded patch (`docs/upstream-lock.md`), offers one +/// [`ExitWindow`] while the child is unreaped, and reaps it when the window is released or +/// `bound` passes, whichever comes first. The session is the same as [`spawn_inheriting`]'s. +pub async fn spawn_observed( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, + inherited: &[RawFd], + bound: Duration, +) -> Result<(PtySession, ExitWatch), String> { + if program.is_empty() { + return Err("command must be a non-empty argv (no shell)".into()); + } + let (opportunity, window) = oneshot::channel(); + let (outcome, end) = oneshot::channel(); + let spawned = codex_utils_pty::spawn_pty_process_observed( + program, + args, + cwd, + env, + &None, + codex_utils_pty::TerminalSize { + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + }, + inherited, + codex_utils_pty::observe::ExitObserver { + opportunity, + bound, + outcome, + }, + ) + .await + .map_err(|err| err.to_string())?; + Ok(( + session(spawned), + ExitWatch { + window: Some(window), + end, + }, + )) } #[cfg(test)] @@ -352,4 +458,128 @@ mod tests { .expect("exit receiver"); assert_eq!(code, 0); } + + /// Whether this process's child `pid` has exited and is still unreaped. + fn exited_unreaped(pid: i32) -> bool { + // SAFETY: an all-zero `siginfo_t` is a valid value for `waitid` to fill. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + // SAFETY: `info` is valid for writes; `WNOWAIT` leaves the child's status unconsumed. + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + // SAFETY: `waitid` filled `info`, or left the zero it was given. + result == 0 && unsafe { info.si_pid() } == pid + } + + /// The number the child printed after `marker`, once it has printed the whole line. + async fn printed_pid(output: &mut mpsc::Receiver>, marker: &str) -> i32 { + let text = collect_until(output, "\n").await; + let line = text.replace("\r\n", "\n"); + let digits: String = line + .split(marker) + .nth(1) + .expect("marker") + .chars() + .take_while(char::is_ascii_digit) + .collect(); + digits.parse().unwrap() + } + + /// An observed spawn without inherited descriptors and one with a kept descriptor, the two + /// Unix paths of the pinned Codex PTY. + async fn spawn_watched( + dir: &Path, + script: &str, + kept: &[RawFd], + bound: Duration, + ) -> (PtySession, ExitWatch) { + spawn_observed( + "/bin/sh", + &["-c".into(), script.into()], + dir, + &shell_env(dir), + kept, + bound, + ) + .await + .expect("spawn") + } + + #[tokio::test] + async fn an_observed_child_is_offered_its_window_before_the_reap() { + let dir = tempfile::tempdir().unwrap(); + let kept = inheritable_at(200); + for inherited in [vec![], vec![std::os::fd::AsRawFd::as_raw_fd(&kept)]] { + let (mut session, mut watch) = spawn_watched( + dir.path(), + "echo pid:$$; exit 3", + &inherited, + Duration::from_secs(10), + ) + .await; + let mut output = session.take_stdout().expect("stdout"); + let pid = printed_pid(&mut output, "pid:").await; + let window = tokio::time::timeout(Duration::from_secs(5), watch.window()) + .await + .expect("window timeout") + .expect("a window"); + // The exited child is a zombie that keeps its PID until the window is released. + assert!(exited_unreaped(pid), "{inherited:?}"); + assert!(!session.has_exited(), "{inherited:?}"); + assert_eq!(window.release(), Ok(())); + let code = tokio::time::timeout(Duration::from_secs(5), session.take_exit().unwrap()) + .await + .expect("exit timeout") + .expect("exit receiver"); + assert_eq!(code, 3, "{inherited:?}"); + assert_eq!(watch.end().await, Some(WindowEnd::Released)); + assert!(!exited_unreaped(pid), "{inherited:?}"); + } + } + + #[tokio::test] + async fn an_unreleased_window_is_reaped_at_its_bound() { + let dir = tempfile::tempdir().unwrap(); + let bound = Duration::from_millis(300); + let (mut session, mut watch) = spawn_watched(dir.path(), "exit 0", &[], bound).await; + let window = tokio::time::timeout(Duration::from_secs(5), watch.window()) + .await + .expect("window timeout") + .expect("a window"); + let offered = std::time::Instant::now(); + let code = tokio::time::timeout(Duration::from_secs(5), session.take_exit().unwrap()) + .await + .expect("exit timeout") + .expect("exit receiver"); + assert_eq!(code, 0); + assert!(offered.elapsed() >= bound - Duration::from_millis(50)); + assert_eq!(watch.end().await, Some(WindowEnd::Expired)); + // A release after the bound is refused: the child was reaped then. + assert_eq!(window.release(), Err(WindowExpired)); + } + + #[tokio::test] + async fn a_dropped_window_releases_the_child_at_once() { + let dir = tempfile::tempdir().unwrap(); + let (mut session, mut watch) = + spawn_watched(dir.path(), "exit 0", &[], Duration::from_secs(10)).await; + let window = tokio::time::timeout(Duration::from_secs(5), watch.window()) + .await + .expect("window timeout") + .expect("a window"); + let dropped = std::time::Instant::now(); + drop(window); + let code = tokio::time::timeout(Duration::from_secs(5), session.take_exit().unwrap()) + .await + .expect("exit timeout") + .expect("exit receiver"); + assert_eq!(code, 0); + assert!(dropped.elapsed() < Duration::from_secs(2)); + assert_eq!(watch.end().await, Some(WindowEnd::Abandoned)); + } } diff --git a/crates/runner/src/launch.rs b/crates/runner/src/launch.rs index e654f6b..1b8ac7c 100644 --- a/crates/runner/src/launch.rs +++ b/crates/runner/src/launch.rs @@ -898,9 +898,9 @@ 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 }; + // DevGuard is asked to observe the scope before the reap and after it, on a pipe + // and on a PTY, whose patched Codex waiter offers the same window (CSRG-U5). + let observes = 2; for _ in 0..250 { if fake.count(OBSERVE) >= observes { break; diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index e65c48d..a98710b 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -289,8 +289,9 @@ pub enum ExitPoint { /// 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. +/// nothing else ever waits for the child. A PTY child's one reaper is the pinned Codex PTY's +/// waiter: with an observer, the recorded patch has it offer the same window before the reap +/// (`codespace_pty::spawn_observed`), bounded the same way. pub type ExitObserver = Arc; /// How long a reaper waits for its exit observer. @@ -364,6 +365,9 @@ enum KillIntent { struct Lifecycle { finished: bool, + /// A PTY root that has exited and waits for its reaper, which offered its exit window + /// (CSRG-U5). A pipe root's reaper sees this with `waitid`; a PTY root's is the Codex waiter. + exited: bool, kill_intent: Option, snapshot: LifecycleSnapshot, } @@ -372,6 +376,7 @@ impl Lifecycle { fn new() -> Self { Self { finished: false, + exited: false, kill_intent: None, snapshot: LifecycleSnapshot { state: ProcessState::Running, @@ -801,8 +806,32 @@ impl InProcessRunner { // portable-pty and the Codex PTY's descriptor-keeping path fork: see // `prepare_fork_spawns`. crate::prepare_fork_spawns(); + // A process with an exit observer gets the same window before the reap as a pipe root + // (CSRG-U5): the patched Codex PTY's waiter offers it, bounded by OBSERVE_BOUND. + let spawn = |inherited: Vec| { + let (program, args, cwd, env) = (&program, &args, &cwd, &env); + let observed = observer.is_some(); + async move { + if observed { + codespace_pty::spawn_observed( + program, + args, + cwd, + env, + &inherited, + OBSERVE_BOUND, + ) + .await + .map(|(session, watch)| (session, Some(watch))) + } else { + codespace_pty::spawn_inheriting(program, args, cwd, env, &inherited) + .await + .map(|session| (session, None)) + } + } + }; let spawned = match &how { - How::Direct => codespace_pty::spawn(&program, &args, &cwd, &env).await, + How::Direct => spawn(Vec::new()).await, #[cfg(feature = "devguard")] How::Managed(managed) => { // The pinned Codex PTY keeps only descriptors that are already inheritable, so @@ -813,21 +842,14 @@ impl InProcessRunner { Ok(()) => { #[cfg(test)] inheritable_window::run(&process_id, managed.descriptors()); - codespace_pty::spawn_inheriting( - &program, - &args, - &cwd, - &env, - &managed.descriptors(), - ) - .await + spawn(managed.descriptors().to_vec()).await } Err(err) => Err(format!("cannot pass the launch descriptors: {err}")), } } }; how.spawned(); - let mut session = spawned.map_err(|err| { + let (mut session, watch) = spawned.map_err(|err| { abort(); ErrorBody::new( ErrorCode::ProcessSpawnFailed, @@ -859,6 +881,21 @@ impl InProcessRunner { reservation.fill(&mut map, slot); } + if let Some(mut watch) = watch { + let (observer, life) = (observer.clone(), lifecycle.clone()); + tokio::spawn(async move { + let Some(window) = watch.window().await else { + return; + }; + // The root has exited and still holds its PID until the window is released. + // Termination and the timeout leave it to its reaper from now on, as a pipe + // root's, so how it ended stays what it was. + life.lock().expect("lifecycle").exited = true; + observe(&observer, ExitPoint::BeforeReap).await; + let _ = window.release(); + }); + } + let out_handle = { let buf = output.clone(); Some(tokio::spawn( @@ -878,7 +915,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. + // The Codex waiter has reaped the root, which a governed process's observer saw + // through the window before the reap. observe(&observer, ExitPoint::AfterReap).await; join_pump(out_handle).await; if let Ok(mut buf) = wait_out.lock() { @@ -896,7 +934,7 @@ impl InProcessRunner { tokio::spawn(async move { tokio::time::sleep(timeout).await; let mut life = timeout_life.lock().expect("lifecycle"); - if life.finished || timeout_session.has_exited() { + if life.finished || life.exited || timeout_session.has_exited() { return; } life.note_kill(KillIntent::Timeout); @@ -1184,7 +1222,7 @@ fn request_kill(slot: &Slot) -> Result { } SessionIo::Pty { session, .. } => { let mut life = slot.lifecycle.lock().expect("lifecycle"); - if life.finished || session.has_exited() { + if life.finished || life.exited || session.has_exited() { return Ok(false); } life.note_kill(KillIntent::Terminated); @@ -2322,35 +2360,216 @@ mod tests { assert_eq!(reaper_gate::reaps(&process_id.0), 1); } + /// Spawn `argv` with `hooks` and a timeout of `timeout_ms`. + async fn spawn_hooked( + runner: &InProcessRunner, + ws: &Workspace, + process_id: &ProcessId, + argv: &[&str], + tty: bool, + timeout_ms: u64, + hooks: ProcessHooks, + ) { + 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(); + runner + .spawn_slotted(ws, req, slot, How::Direct, hooks) + .await + .unwrap(); + } + + /// Whether the pinned Codex PTY's waiter has reaped `process_id`'s root. + fn pty_reaped(runner: &InProcessRunner, process_id: &ProcessId) -> bool { + let map = runner.inner.lock().unwrap(); + match &map[&process_id.0].io { + SessionIo::Pty { session, .. } => session.has_exited(), + SessionIo::Pipe { .. } => panic!("{} is not a PTY child", process_id.0), + } + } + + /// A PTY child with an observer gets a pipe root's window before the reap (CSRG-U5): the + /// patched Codex waiter offers it while the exited root is unreaped, and reaps it once the + /// observer has returned. #[tokio::test] - async fn a_pty_childs_observer_is_called_after_the_reap_only() { + async fn a_pty_childs_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-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)) + let (runner, process_id, seen) = (runner.clone(), process_id.clone(), seen.clone()); + Arc::new(move |point| { + let reaped = pty_reaped(&runner, &process_id); + seen.lock().unwrap().push((point, reaped)); + }) }; spawn_observed( &runner, &ws, &process_id, - &["/bin/sh", "-c", "exit 0"], + &["/bin/sh", "-c", "exit 4"], true, observer, ) .await; - wait_exited(&runner, &process_id).await; + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.exit_code, Some(4)); for _ in 0..250 { - if !seen.lock().unwrap().is_empty() { + if seen.lock().unwrap().len() == 2 { 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]); + // (where, reaped): unreaped in the window, reaped after it. + assert_eq!( + *seen.lock().unwrap(), + [(ExitPoint::BeforeReap, false), (ExitPoint::AfterReap, true)] + ); + } + + /// As a pipe root's: an observer that does not return holds a PTY root at most the bound. + #[tokio::test] + async fn a_pty_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-pty-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"], + true, + observer, + ) + .await; + let status = wait_exited(&runner, &process_id).await; + assert!(started.elapsed() < OBSERVE_BOUND + Duration::from_millis(900)); + assert_eq!(status.exit_code, Some(5)); + } + + /// An observer that fails releases the exited root at once, on a pipe and on a PTY. + #[tokio::test] + async fn a_failing_observer_does_not_hold_the_child() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + for tty in [false, true] { + let process_id = ProcessId(format!("proc-observer-fails-{tty}")); + let observer: ExitObserver = Arc::new(|point| { + if point == ExitPoint::BeforeReap { + panic!("the observer failed"); + } + }); + let started = Instant::now(); + spawn_observed( + &runner, + &ws, + &process_id, + &["/bin/sh", "-c", "exit 6"], + tty, + observer, + ) + .await; + let status = wait_exited(&runner, &process_id).await; + assert!(started.elapsed() < Duration::from_secs(1), "tty={tty}"); + assert_eq!(status.exit_code, Some(6), "tty={tty}"); + } + } + + /// As a pipe root's (CSRG-U5): termination and the timeout while a PTY root waits in its + /// window neither kill it nor change how it ended, and its reaper reaps it once released. + #[tokio::test] + async fn an_exited_pty_child_keeps_how_it_ended_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-window-race".into()); + let (opened, window_open) = std::sync::mpsc::channel::<()>(); + let (release, released) = std::sync::mpsc::channel::<()>(); + let released = Mutex::new(released); + let observer: ExitObserver = Arc::new(move |point| { + if point == ExitPoint::BeforeReap { + let _ = opened.send(()); + let _ = released + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(10)); + } + }); + let kills = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = kills.clone(); + spawn_hooked( + &runner, + &ws, + &process_id, + &["/bin/sh", "-c", "exit 3"], + true, + 300, + ProcessHooks { + observer: Some(observer), + on_kill: Some(Arc::new(move || { + counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })), + }, + ) + .await; + let deadline = Instant::now() + Duration::from_secs(10); + while window_open.try_recv().is_err() { + assert!(Instant::now() < deadline, "the window did not open"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + runner.terminate(&process_id).await.unwrap(); + // The 300 ms timeout passes while the root is in its window. + tokio::time::sleep(Duration::from_millis(500)).await; + assert!(!pty_reaped(&runner, &process_id)); + let status = runner.process_status(&process_id).await.unwrap(); + assert_eq!(status.state, ProcessState::Running); + release.send(()).unwrap(); + let status = wait_exited(&runner, &process_id).await; + assert_eq!( + (status.exit_code, status.termination), + (Some(3), Some(ProcessTermination::Exited)) + ); + assert_eq!(kills.load(std::sync::atomic::Ordering::SeqCst), 0); + } + + /// A PTY root's output drains in full while the exited root waits in its window. + #[tokio::test] + async fn a_pty_childs_output_drains_while_it_is_unreaped() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})); + let process_id = ProcessId("proc-window-output".into()); + let observer: ExitObserver = Arc::new(|point| { + if point == ExitPoint::BeforeReap { + std::thread::sleep(Duration::from_millis(200)); + } + }); + spawn_observed( + &runner, + &ws, + &process_id, + &["/bin/sh", "-c", "head -c 200000 /dev/zero | tr '\\0' x"], + true, + observer, + ) + .await; + let status = wait_exited(&runner, &process_id).await; + assert_eq!(status.exit_code, Some(0)); + let output = wait_chunk(&runner, &process_id).await; + assert_eq!(output.matches('x').count(), 200_000); + let status = runner.process_status(&process_id).await.unwrap(); + assert_eq!(status.output_retained_from, 0); } /// Spawn `argv` with a kill hook that counts its calls. From 70a244189a1477d3169b91a125dab9a599163714 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 09:05:32 +0900 Subject: [PATCH 2/3] devguard, server: governed PTY window tests against DevGuard's fixture authority (CSRG-U5) - The fixture authority binary gains three commands, macOS only: `pause` and `resume` for its background reconciler, so that only an owner's Observe reconciles meanwhile, and `hold ` for its lock, as a slow transition would hold it. - A governed PTY root that leaves a descendant ignoring the terminal's hangup. With the reconciler paused around the exit, only the window's observation can adopt the descendant. The attempt stays charged while the descendant runs and is released after it ends. With observed spawns switched off, the old after-reap-only behaviour, this test fails with the attempt Suspect. - A stalled observation: the authority is held through the window. The root is reaped at the bound, and the descendant, still running when DevGuard can look again, cannot be proven the scope's. The attempt stays Suspect, also after the descendant ends. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/devguard/examples/fixture_authority.rs | 55 +++++++- crates/server/src/devguard.rs | 16 +++ crates/server/src/mcp.rs | 121 ++++++++++++++++++ 3 files changed, 186 insertions(+), 6 deletions(-) diff --git a/crates/devguard/examples/fixture_authority.rs b/crates/devguard/examples/fixture_authority.rs index afdc82a..7b21c94 100644 --- a/crates/devguard/examples/fixture_authority.rs +++ b/crates/devguard/examples/fixture_authority.rs @@ -11,7 +11,10 @@ //! 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). 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 +//! it admits again (CSRG-U5). `pause` and `resume` lines pause and resume its background +//! reconciler, so that only an owner's `Observe` reconciles meanwhile, and a `hold ` line +//! holds its lock for that many milliseconds, as a slow transition would, and is answered once +//! the lock is held (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}; @@ -38,18 +41,34 @@ fn main() { let mut out = std::io::stdout().lock(); writeln!(out, "{}", fixture.settings).unwrap(); out.flush().unwrap(); + let mut holders = Vec::new(); for line in std::io::stdin().lock().lines() { - match line.unwrap().trim() { - "instances" => writeln!(out, "{}", fixture.instances()).unwrap(), - "charged" => writeln!(out, "{}", fixture.charged()).unwrap(), - "restart" => { + let line = line.unwrap(); + let mut words = line.split_whitespace(); + match (words.next(), words.next()) { + (Some("instances"), None) => writeln!(out, "{}", fixture.instances()).unwrap(), + (Some("charged"), None) => writeln!(out, "{}", fixture.charged()).unwrap(), + (Some("restart"), None) => { fixture = fixture.restart(&base); writeln!(out, "{}", json!({"restarted": true})).unwrap() } - other => panic!("unknown command {other:?}"), + (Some(command @ ("pause" | "resume")), None) => { + let paused = command == "pause"; + fixture.pause(paused); + writeln!(out, "{}", json!({ "paused": paused })).unwrap() + } + (Some("hold"), Some(ms)) => { + let ms: u64 = ms.parse().unwrap(); + holders.push(fixture.hold(ms)); + writeln!(out, "{}", json!({ "held_ms": ms })).unwrap() + } + _ => panic!("unknown command {line:?}"), } out.flush().unwrap(); } + for holder in holders { + holder.join().unwrap(); + } fixture.stop(); } @@ -178,6 +197,30 @@ impl Fixture { panic!("a restart is only exercised with native evidence"); } + /// Pause or resume the background reconciler. Owner requests such as `Observe` still + /// reconcile while it is paused. + #[cfg(target_os = "macos")] + fn pause(&self, paused: bool) { + self.authority.pause_reconciler(paused); + } + + #[cfg(not(target_os = "macos"))] + fn pause(&self, _paused: bool) { + panic!("the reconciler is only paused with native evidence"); + } + + /// Hold the authority's lock for `ms` milliseconds on another thread; returns once held. + #[cfg(target_os = "macos")] + fn hold(&self, ms: u64) -> std::thread::JoinHandle<()> { + self.authority + .hold_authority(std::time::Duration::from_millis(ms)) + } + + #[cfg(not(target_os = "macos"))] + fn hold(&self, _ms: u64) -> std::thread::JoinHandle<()> { + panic!("the authority is only held with native evidence"); + } + fn stop(self) { #[cfg(target_os = "macos")] self.authority.stop().unwrap(); diff --git a/crates/server/src/devguard.rs b/crates/server/src/devguard.rs index 6d1a1d9..f40946f 100644 --- a/crates/server/src/devguard.rs +++ b/crates/server/src/devguard.rs @@ -1357,6 +1357,22 @@ pub(crate) mod tests { pub(crate) fn restart(&mut self) { assert_eq!(self.ask("restart"), serde_json::json!({"restarted": true})); } + + /// Pause or resume the authority's background reconciler; an owner's `Observe` still + /// reconciles meanwhile (CSRG-U5). + pub(crate) fn pause_reconciler(&mut self, paused: bool) { + let command = if paused { "pause" } else { "resume" }; + assert_eq!(self.ask(command), serde_json::json!({ "paused": paused })); + } + + /// Hold the authority's lock for `ms` milliseconds, as a slow transition would; returns + /// once it is held (CSRG-U5). + pub(crate) fn hold(&mut self, ms: u64) { + assert_eq!( + self.ask(&format!("hold {ms}")), + serde_json::json!({ "held_ms": ms }) + ); + } } impl Drop for FixtureAuthority { diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 944ea48..233b482 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -3788,6 +3788,127 @@ mod devguard_tests { assert_eq!(released(&mut fixture), (Vec::new(), 0)); } + /// A gateway with DevGuard's fixture authority and launch helper, for the PTY window tests. + fn pty_window_gateway( + fixture: &FixtureAuthority, + helper: &std::path::Path, + root: &std::path::Path, + ) -> (CodeSpace, Arc) { + 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(root), + store.clone(), + RuntimeBackend::in_process(release(&store)), + ); + (handler, store) + } + + /// The PTY counterpart of the test above (CSRG-U5): the descendant ignores the terminal's + /// hangup, so it outlives the session leader. The patched Codex waiter keeps the exited + /// root unreaped for its window, in which DevGuard observes the scope and adopts the + /// descendant; the attempt stays charged until the descendant ends and is then released. + /// The reconciler is paused around the exit, so only that observation can adopt it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_surviving_pty_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 (handler, _store) = pty_window_gateway(&fixture, &helper, dir.path()); + fixture.pause_reconciler(true); + let script = "trap '' HUP; sleep 3 /dev/null 2>&1 & echo started"; + let result = executed( + &handler, + exec_params("gov", &["/bin/sh", "-c", script], true), + ) + .await + .unwrap(); + let (output, status) = finished(&handler, &result.process_id).await; + assert_eq!((output.as_str(), status.exit_code), ("started\n", Some(0))); + fixture.pause_reconciler(false); + // The root has exited and been reaped; its descendant still runs in the scope. + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + let phases = fixture.phases(); + assert_eq!(phases.len(), 1, "the descendant keeps the attempt charged"); + assert_ne!(phases[0].1, "suspect", "{phases:?}"); + assert_eq!(released(&mut fixture), (Vec::new(), 0)); + } + + /// When DevGuard cannot observe a governed PTY root in its window (CSRG-U5): the window ends + /// at the observer's bound, the root is reaped, and the descendant it left, still running + /// when DevGuard can look again, cannot be proven the scope's. The attempt stays charged as + /// Suspect, also after the descendant ends. The reconciler is paused and the authority held + /// from just before the exit. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_stalled_observation_leaves_a_pty_survivor_suspect() { + 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 (handler, _store) = pty_window_gateway(&fixture, &helper, dir.path()); + let go = dir.path().join("go"); + let script = format!( + "while [ ! -e '{}' ]; do sleep 0.05; done; \ + trap '' HUP; sleep 6 /dev/null 2>&1 & echo started", + go.display() + ); + let result = executed( + &handler, + exec_params("gov", &["/bin/sh", "-c", &script], true), + ) + .await + .unwrap(); + let (attempts, _) = fixture.charged(); + assert_eq!(attempts.len(), 1); + // From here only an observation could adopt the descendant, and it will stall. + fixture.pause_reconciler(true); + let held = std::time::Instant::now(); + fixture.hold(4_000); + std::fs::write(&go, b"").unwrap(); + let (output, status) = finished(&handler, &result.process_id).await; + assert_eq!((output.as_str(), status.exit_code), ("started\n", Some(0))); + // Reaped at the observer's bound, while the authority was still held. + assert!( + held.elapsed() < std::time::Duration::from_millis(3_900), + "{:?}", + held.elapsed() + ); + // Once the hold is over, the reconciler finds the root reaped and the descendant + // running but unproven. + tokio::time::sleep(std::time::Duration::from_millis(4_500).saturating_sub(held.elapsed())) + .await; + fixture.pause_reconciler(false); + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + let suspect = [(attempts[0].clone(), "suspect".to_owned())]; + assert_eq!(fixture.phases(), suspect); + // The descendant has ended by now; the lost tracking keeps the attempt charged. + tokio::time::sleep(std::time::Duration::from_millis(8_000).saturating_sub(held.elapsed())) + .await; + assert_eq!(fixture.phases(), suspect); + } + /// 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 From 634a19fde4a3159021553a0ebfcbf735ac0a4ee7 Mon Sep 17 00:00:00 2001 From: Seongjae Date: Mon, 12 Oct 2026 09:07:48 +0900 Subject: [PATCH 3/3] docs: governed PTY executions are observed before the reap (CSRG-U5) The pages that described PTY observation after the reap as an interim state now describe the window, in English and Korean: - The roadmap's CSRG-U5 section: - observation before the reap, for pipe and PTY alike; - termination and the timeout while a PTY root waits in its window; - the PTY paragraph; - the "not claimed" line; - the current status. - The patch record's "Use in CodeSpace" row. - The architecture page's current behaviour. - A follow-up sentence in the policy's case study. Translations recorded. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- docs/devguard-integration.md | 10 +++++----- docs/ko/architecture.md | 2 +- docs/ko/devguard-integration.md | 10 +++++----- docs/ko/upstream-lock.md | 2 +- docs/ko/upstream-patches.md | 2 +- docs/translations.json | 16 ++++++++-------- docs/upstream-lock.md | 2 +- docs/upstream-patches.md | 2 +- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b6d6de0..4df63a7 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 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)). +**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; a governed PTY execution gets the same observation in the window the [recorded Codex patch](upstream-lock.md#codex-downstream-patch) gives the PTY's waiter ([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 05a5f59..037542e 100644 --- a/docs/devguard-integration.md +++ b/docs/devguard-integration.md @@ -8,7 +8,7 @@ [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. 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. +**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 execution's scope before its root is reaped, on a pipe through CodeSpace's own reaper and on a PTY through the pinned Codex PTY's waiter with the [recorded patch](upstream-lock.md#codex-downstream-patch); 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. @@ -234,11 +234,11 @@ Tests check each with controls: a deliberately inheritable descriptor; stand-in 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. +**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. While a governed PTY root waits in its window before the reap (below), termination and the timeout leave it to that waiter, as for an exited pipe root, so how it ended is reported unchanged. -**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. +**Observation before the reap.** For a governed execution, the root's one reaper detects its exit without reaping it and has DevGuard `Observe` the scope while the exited root still holds its PID: on a pipe, CodeSpace's reaper with `waitid` and `WNOWAIT`; on a PTY, the pinned Codex PTY's waiter, whose recorded patch offers CodeSpace a window before it reaps. 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. +**PTY.** A governed PTY execution is spawned through the patch's observed spawn (`codespace-pty`'s `spawn_observed`), with the same 2 s bound. As soon as the root has exited, Codex's waiter offers the window, CodeSpace runs the same observation as for a pipe root, and the waiter reaps once the observation returns or the bound passes. A descendant that ignores the terminal's hangup is therefore adopted as a pipe root's is, and a new descendant is left unproven only when the observation fails or stalls past the bound, as on a pipe. Ungoverned PTY executions spawn exactly as before. Before this, PTY executions were observed only after Codex's reap, the interim state of the owner's 2026-10-11 choice. **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. @@ -250,7 +250,7 @@ CSRG-U5 completes a governed execution's lifecycle after its launch: who reaps i **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. +**Supported** as for CSRG-U4. **Not claimed:** CS-RG qualification (CSRG-U6) and Linux before DG-LINUX. ## CS-RG work order diff --git a/docs/ko/architecture.md b/docs/ko/architecture.md index 532b624..8b8096d 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합니다. 파이프 경로에서는 자식마다 작업 하나가 reap하고, 제한 시간과 종료 요청은 자식이 아직 reap되지 않았을 때 signal만 보냅니다(CSRG-U5). 관리 대상 파이프 실행에서는 그 작업이 reap하지 않고 종료를 감지해 DevGuard에 scope를 관찰하게 한 뒤 reap합니다([실행 뒤 생명주기](devguard-integration.md#devguard-lifecycle)). +**현재 동작.** 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합니다. 관리 대상 PTY 실행은 [기록된 Codex 패치](upstream-lock.md#codex-downstream-patch)가 PTY의 waiter에 주는 창에서 같은 관찰을 받습니다([실행 뒤 생명주기](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 659748a..f2ae02d 100644 --- a/docs/ko/devguard-integration.md +++ b/docs/ko/devguard-integration.md @@ -8,7 +8,7 @@ [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 없이 실행하는 것은 없다. 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는 독립 저장소에서 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는 관리 대상 실행의 scope를 root가 reap되기 전에 관찰한다. pipe에서는 CodeSpace 자신의 reaper가, PTY에서는 [기록된 패치](upstream-lock.md#codex-downstream-patch)를 적용한 고정 Codex PTY의 waiter가 그 기회를 준다. 관리 대상 프로세스를 죽이면 그 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 상태는 바꾸지 않는다. @@ -234,11 +234,11 @@ DevGuard는 helper의 부모가 등록된 소유자이기를 요구하므로 실 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한다. +**reaper 하나.** pipe 자식의 reaper는 그 자식을 기다리는 task 하나다. timeout, `terminate_process`, workspace 종료, 실패한 실행의 정리는 의도를 기록하고 자식이 아직 reap되지 않았을 때만 signal을 보내며, 자식을 기다리지 않는다. 종료 시 reaper는 runtime과 함께 사라진다. 실행 중인 자식은 죽고, tokio의 orphan queue가 한 번 reap한다. PTY 자식은 이전처럼 고정된 Codex PTY의 waiter가 reap한다. 관리 대상 PTY root가 reap 전 창(아래)에서 기다리는 동안 종료와 timeout은 종료한 pipe root처럼 그 waiter에 맡기므로, 어떻게 끝났는지는 바뀌지 않고 보고된다. -**reap 전 관찰.** 관리 대상 pipe 실행에서 reaper는 root의 종료를 reap하지 않고 감지하며(`WNOWAIT`를 준 `waitid`), 종료한 root가 아직 PID를 갖고 있는 동안 DevGuard에 scope를 `Observe`하게 한다. 그다음 root를 reap하고 종료를 기록한 뒤 DevGuard에 한 번 더 관찰하게 한다. 그래서 scope에 남은 자손은 계속 그 scope의 것이다. 자손이 실행되는 동안 attempt는 계속 자원을 차지하고, 자손이 끝나면 DevGuard가 해제한다. 관찰마다 상한(2초)이 있으므로 reap 전 관찰이 종료 상태를 늦추는 시간도 그 이하다. 시간이 지나거나 실패한 관찰은 건너뛰고 reap을 진행한다. 관찰은 reaper를 늘리지 않고 자식을 reap하지 않은 채로 두지도 않는다. 관리하지 않는 실행은 관찰하지 않는다. +**reap 전 관찰.** 관리 대상 실행에서 root의 reaper는 그 종료를 reap하지 않고 감지하며, 종료한 root가 아직 PID를 갖고 있는 동안 DevGuard에 scope를 `Observe`하게 한다. pipe에서는 CodeSpace의 reaper가 `WNOWAIT`를 준 `waitid`로 감지하고, PTY에서는 고정된 Codex PTY의 waiter가 기록된 패치에 따라 reap 전에 CodeSpace에 창을 준다. 그다음 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는 그것과 함께 병합하고, 그렇지 않으면 병합 전에 소유자에게 다시 묻는다. +**PTY.** 관리 대상 PTY 실행은 패치의 관찰 spawn(`codespace-pty`의 `spawn_observed`)으로 같은 2초 상한을 두고 시작한다. root가 종료하면 Codex의 waiter가 창을 열고, CodeSpace는 pipe root와 같은 관찰을 하며, waiter는 관찰이 돌아오거나 상한이 지나면 reap한다. 그래서 터미널의 hangup을 무시하는 자손도 pipe root의 자손처럼 채택된다. 새 자손이 증명되지 않은 채 남는 것은 pipe와 마찬가지로 관찰이 실패하거나 상한을 넘겨 멈췄을 때뿐이다. 관리하지 않는 PTY 실행은 이전과 똑같이 시작한다. 이 변경 전에는 소유자의 2026-10-11 선택에 따른 임시 상태로, PTY 실행을 Codex의 reap 뒤에만 관찰했다. **종료.** CodeSpace가 관리 대상 프로세스를 죽이면(`terminate_process`, workspace 종료, timeout) DevGuard에도 scope를 `Terminate`하게 한다. DevGuard가 다시 확인할 수 있는 모든 identity, 즉 구성원과 알려진 이탈 프로세스에 SIGKILL을 보낸다. 이 요청은 자신의 thread에서 돌고 기다리지 않으므로 CodeSpace 자신의 kill은 DevGuard를 기다리지 않는다. signal을 보낸 것은 해제가 아니다. DevGuard는 scope의 끝을 관찰한 뒤에 attempt를 해제한다. @@ -250,7 +250,7 @@ CSRG-U5는 관리 대상 실행의 실행 뒤 생명주기를 완성한다. 누 **재실행 없음.** 첫 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. +**지원**은 CSRG-U4와 같다. **주장하지 않는 것:** CS-RG qualification(CSRG-U6), DG-LINUX 전의 Linux. ## CS-RG 작업 순서 diff --git a/docs/ko/upstream-lock.md b/docs/ko/upstream-lock.md index 4e720f4..c2917a4 100644 --- a/docs/ko/upstream-lock.md +++ b/docs/ko/upstream-lock.md @@ -38,7 +38,7 @@ CodeSpace는 [고정 버전의 Codex 수정 정책](upstream-patches.md)에 따 | 파일 | `codex-rs/utils/pty/src`의 `observe.rs`(신규), `observe_tests.rs`(신규, 테스트 전용), `pty.rs`, `lib.rs`. 제품 코드 304줄 추가, 5줄 삭제, 테스트 9개 | | 기능 | spawn 전에 `observe::ExitObserver`를 등록하는 `spawn_pty_process_observed`. 자식의 단일 waiter가 회수하지 않고 종료를 보고(`WNOWAIT`를 쓴 `waitid`), 관찰자에게 그 bound 안에서 한 번의 기회를 준 뒤 전과 같이 회수하고 `Completed`, `TimedOut`, `Failed`, `Unavailable` 중 하나를 보고. 관찰자가 없으면 모든 경로가 그대로. Unix가 아닌 플랫폼은 관찰 spawn을 거부 | | 이유 | Codex가 루트를 회수하기 전에 관리 PTY 실행의 scope를 관찰하기 위함([CSRG-U5](devguard-integration.md#devguard-lifecycle)). 2026-10-11 소유자 결정. 계약은 [업스트림 어댑터 작업 명세](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-cs-dg-upstream-adapter-work-spec-1.md#8-preferred-investigation-b-an-upstream-owned-bounded-pre-reap-observation-contract) 8절 | -| CodeSpace 사용 | 아직 없음. PTY 어댑터는 여전히 `spawn_pty_process`를 호출하며, 관찰자 연결은 별도 변경에서 함 | +| CodeSpace 사용 | 관리 대상 PTY 실행([CSRG-U5](devguard-integration.md#devguard-lifecycle)). PTY 어댑터의 `spawn_observed`가 CodeSpace의 관찰 상한(2초)으로 관찰자를 등록하고, 그 창에서 DevGuard의 `Observe`가 reap 전에 이루어짐. 관리하지 않는 PTY 실행은 여전히 `spawn_pty_process`를 호출함 | | 출처 | W3 실험 B의 관찰자([DevGuard 결정 패킷 3.2절](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132))를 이 릴리스의 두 Unix PTY 경로로 옮긴 것. 수정한 파일마다 CodeSpace 수정 고지가 있음(Apache-2.0 4(b)) | | 검증 | 패치 적용 tree에서 함. Linux x86_64: crate 테스트 36개를 30회 반복해 모두 통과. macOS: 테스트 38개를 30회 반복해 모두 통과, DevGuard `6e7e065`로 W3 실험 B의 B1-B13 통과. Linux, macOS, Windows 대상 clippy 통과 | | 업스트림 | Codex는 외부 pull request를 받지 않음. 기능 요청 초안만 있고 제출하지 않음 | diff --git a/docs/ko/upstream-patches.md b/docs/ko/upstream-patches.md index d12a13c..30941b1 100644 --- a/docs/ko/upstream-patches.md +++ b/docs/ko/upstream-patches.md @@ -120,7 +120,7 @@ Codex는 코드 변경을 자기 팀에서만 받고, 기능 요청은 issue tra - **문제.** 고정한 `codex-utils-pty`는 자기 task에서 자식을 회수하며 회수 전 hook이 없습니다. 그래서 관리형 PTY 실행은 루트가 회수되기 전에 관찰할 수 없습니다([DevGuard 연동](devguard-integration.md#devguard-lifecycle)). - **검토한 것**(2026-10-11, [DevGuard #14](https://github.com/novelKR/DevGuard/issues/14#issuecomment-6110596020)): 문서화된 제약으로 두기, Codex의 공개 `spawn_from_driver` 위에 CodeSpace 자체 PTY를 두기, Codex에 회수 전 hook을 넣기. [업스트림 어댑터 명세](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-cs-dg-upstream-adapter-work-spec-1.md#8-preferred-investigation-b-an-upstream-owned-bounded-pre-reap-observation-contract)는 업스트림이 소유하고 시간 제한이 있는 회수 전 관찰을 우선하며, CodeSpace 자체 PTY는 CodeSpace가 Codex에 맡긴 메커니즘을 CodeSpace 안으로 옮기는 일입니다. -- **결정.** 소유자는 hook을 골랐고, 수명 주기 작업과 병행했습니다. [#92](https://github.com/novelKR/CodeSpace/pull/92)는 PTY를 회수 뒤에 관찰하는 상태로 병합했고, hook은 별도 변경으로 준비했습니다. 관찰 기능은 먼저 Codex 사전 릴리스에서 만들어 시험했고([DevGuard W3 결정 자료](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132) 3.2절), 그 뒤 `rust-v0.154.0`으로 옮겼습니다. +- **결정.** 소유자는 hook을 골랐고, 수명 주기 작업과 병행했습니다. [#92](https://github.com/novelKR/CodeSpace/pull/92)는 PTY를 회수 뒤에 관찰하는 상태로 병합했고, hook은 별도 변경으로 준비했습니다. 관찰 기능은 먼저 Codex 사전 릴리스에서 만들어 시험했고([DevGuard W3 결정 자료](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132) 3.2절), 그 뒤 `rust-v0.154.0`으로 옮겼습니다. 이어서 후속 변경이 이를 관리 대상 PTY 실행에 연결했습니다([실행 뒤 생명주기](devguard-integration.md#devguard-lifecycle)). - **형식.** 서브모듈이 가리킬 커밋을 담은 fork, 또는 공식 릴리스 위의 패치 파일. 어느 쪽이든 버전을 옮길 때 다시 옮겨야 하며, `rust-v0.159.x`에서는 이 PTY 코드가 바뀝니다. 소유자는 패치 파일을 골랐습니다. 서브모듈은 공식 릴리스에 남고, 변경과 그 기록, 이후의 이식은 모두 CodeSpace에서 검토하며, 따로 최신으로 유지할 저장소가 생기지 않습니다. 이 문서는 그 선택을 규칙으로 정한 것입니다. diff --git a/docs/translations.json b/docs/translations.json index 4c06e33..fda77d5 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -75,8 +75,8 @@ "최소-도입-조건", "최초-gateway-복구-범위" ], - "source_sha256": "7c67aaf956dd4ef71abd7d3f778b2aaf40792efaed55c9fef8bdacbe11f5e6d7", - "translation_sha256": "7af753ee9c0b54ac17753db23dea09fbafcb7a1c52a3f83f48286228cd2dfe21" + "source_sha256": "b08b20641a0bbd91a9635e31cf5051171d7844d5ae19c8c5d5ce13b30dfe2228", + "translation_sha256": "34927ccff3fd854b9041e202f2a807cd89ac3eb65e632598e9fed3a02fefe11c" }, { "id": "overview", @@ -291,8 +291,8 @@ "현재-실행-구조", "확장-시-유지할-경계" ], - "source_sha256": "524cd7d63660e54377bd3916b461cf1d3efa92308feadaab6ce332cde4b68625", - "translation_sha256": "5e88314d135b2184fe06255ec1fd323f972c7475e33bd2f4fd6d9fc503a65cf2" + "source_sha256": "df2bd3224b5f47d28038f11bf065ec48ed08bc8a4bc30c3163adcec65b0eecba", + "translation_sha256": "300c857fa22a155c01070a7c4f7867e43085d8c15773acd19e60d745917cd2d2" }, { "id": "execution-substrate", @@ -649,8 +649,8 @@ "하위-패치", "확인과-갱신" ], - "source_sha256": "f6fe4106df84b502eaaea65d40c666ade982253f658a46e8de769f7911d80b7f", - "translation_sha256": "bcdcb7a4e9b1080f5b5a66159dd1d693b82ed6bf0dccc6e346e8dfc33be0ca16" + "source_sha256": "55964a2fefa072c9f4642f667a98195a4ad7213b0dc76b1368d44eecbcafe640", + "translation_sha256": "ef64b2a6158b7cbd7956243620d6af4ec7557d07e31b59e9a6c78b394ea8bec0" }, { "id": "upstream-update", @@ -747,8 +747,8 @@ "형식", "회수-전-관찰-첫-패치" ], - "source_sha256": "f48b33c4789ba32780e63ac790c98c45fa69c21cf8398b7015168c73fcaa3272", - "translation_sha256": "7d84836d7c0e01504955e61ff72029b5650381811c71496cb31ed93622d0e5f1" + "source_sha256": "b54b42d0c9eea5b911aea5bfab6829d33606cd739a74fe6efba16503c6fa44f3", + "translation_sha256": "5b1da3fb965b81c348625a0760c99fafc651f473bf1cc7fec371af8a98354c21" }, { "id": "documentation", diff --git a/docs/upstream-lock.md b/docs/upstream-lock.md index 2fd5c1c..a4a3fdd 100644 --- a/docs/upstream-lock.md +++ b/docs/upstream-lock.md @@ -36,7 +36,7 @@ CodeSpace carries one patch to `codex-utils-pty`, under the [policy for changing | Files | `observe.rs` (new), `observe_tests.rs` (new, tests only), `pty.rs` and `lib.rs` in `codex-rs/utils/pty/src`: 304 product lines added and 5 removed, and 9 tests | | Capability | `spawn_pty_process_observed` with an `observe::ExitObserver` registered before the spawn. The child's single waiter sees the exit without reaping it (`waitid` with `WNOWAIT`), gives the observer one opportunity for at most the observer's bound, then reaps it as before and reports `Completed`, `TimedOut`, `Failed` or `Unavailable`. Without an observer every path is unchanged. Platforms other than Unix refuse an observed spawn | | Reason | observing a governed PTY execution's scope before Codex reaps its root ([CSRG-U5](devguard-integration.md#devguard-lifecycle)), as the owner chose on 2026-10-11; the contract is section 8 of the [upstream adapter work specification](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-cs-dg-upstream-adapter-work-spec-1.md#8-preferred-investigation-b-an-upstream-owned-bounded-pre-reap-observation-contract) | -| Use in CodeSpace | none yet: the PTY adapter still calls `spawn_pty_process`. A separate change connects the observer | +| Use in CodeSpace | governed PTY executions ([CSRG-U5](devguard-integration.md#devguard-lifecycle)): the PTY adapter's `spawn_observed` registers an observer with CodeSpace's observation bound (2 s), and its window carries DevGuard's `Observe` before the reap. Ungoverned PTY executions still call `spawn_pty_process` | | Provenance | ported from the observer of W3 experiment B ([DevGuard decision packet, section 3.2](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132)) to both Unix PTY paths of this release. Each modified file carries a CodeSpace modification notice (Apache-2.0 section 4(b)) | | Verification | on the patched tree: Linux x86_64, the crate's 36 tests in each of 30 runs; macOS, its 38 tests in each of 30 runs and W3 experiment B's cases B1-B13 against DevGuard `6e7e065`. Clippy is clean for Linux, macOS and Windows targets | | Upstream | Codex accepts no external pull requests. A feature request is drafted and not submitted | diff --git a/docs/upstream-patches.md b/docs/upstream-patches.md index bc2c685..61e4d19 100644 --- a/docs/upstream-patches.md +++ b/docs/upstream-patches.md @@ -120,7 +120,7 @@ Each was closed in CodeSpace or documented and pinned by a test. None moved the - **Gap.** The pinned `codex-utils-pty` reaps the child in its own task, with no hook before the reap, so a governed PTY execution cannot be observed before its root is reaped ([DevGuard integration](devguard-integration.md#devguard-lifecycle)). - **Weighed** on 2026-10-11 ([DevGuard #14](https://github.com/novelKR/DevGuard/issues/14#issuecomment-6110596020)): keeping a documented limitation, a CodeSpace-owned PTY on Codex's public `spawn_from_driver`, or a pre-reap hook in Codex. The [upstream adapter specification](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-cs-dg-upstream-adapter-work-spec-1.md#8-preferred-investigation-b-an-upstream-owned-bounded-pre-reap-observation-contract) prefers an upstream-owned, bounded observation before the reap, and a CodeSpace-owned PTY would move a mechanism CodeSpace delegates to Codex into CodeSpace. -- **Chosen.** The owner chose the hook, in parallel with the lifecycle work: [#92](https://github.com/novelKR/CodeSpace/pull/92) merged with PTY observation after the reap, and the hook was prepared as a separate change. The observer had first been built and tested on a Codex prerelease ([DevGuard W3 decision packet](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132), section 3.2) and was ported to `rust-v0.154.0`. +- **Chosen.** The owner chose the hook, in parallel with the lifecycle work: [#92](https://github.com/novelKR/CodeSpace/pull/92) merged with PTY observation after the reap, and the hook was prepared as a separate change. The observer had first been built and tested on a Codex prerelease ([DevGuard W3 decision packet](https://github.com/novelKR/DevGuard/blob/668750b4ab6ad582904c3d05ffceffa062b26243/docs/handoff/2026-09-28-w3-decision-packet.md#32-experiment-b-backend-owned-pre-reap-observation-packet-132), section 3.2) and was ported to `rust-v0.154.0`. A follow-up then connected it to governed PTY executions ([lifecycle after launch](devguard-integration.md#devguard-lifecycle)). - **Form.** A fork whose commit the submodule would name, or a patch file on the official release. Either has to be ported when the pin moves, and `rust-v0.159.x` changes this PTY code. The owner chose the patch file: the submodule stays official, and the change, its record and each later port are reviewed in CodeSpace, with no second repository to keep current. This page makes that choice the rule.