From 57714f7df8e4038de54a91989a42c0d1e87c88cc Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Tue, 20 Oct 2026 23:15:07 +0800 Subject: [PATCH] [CTX-0008] feat(execution): host spawn preparer hook + Core IPC pin (#2249) --- Cargo.lock | 21 +--- Cargo.toml | 2 +- README.md | 12 +++ src/lib.rs | 2 +- src/registry.rs | 89 +++++++++++++++- tests/job_spawn_preparer.rs | 201 ++++++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 23 deletions(-) create mode 100644 tests/job_spawn_preparer.rs diff --git a/Cargo.lock b/Cargo.lock index efdcd65..c5d41d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,24 +82,23 @@ dependencies = [ [[package]] name = "bitty-ipc" version = "0.0.1" -source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e9714e7#e9714e7f2a0ea243cdd2e209ed606a9bdc56fd7c" +source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e6a2c0c#e6a2c0c2d23fefebc61ca300c14f5017d0c2f83f" dependencies = [ "bitty-ipc-api", "bitty-ipc-auth", "bitty-ipc-core", "bitty-ipc-devtools", - "bitty-ipc-mcp", ] [[package]] name = "bitty-ipc-api" version = "0.0.1" -source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e9714e7#e9714e7f2a0ea243cdd2e209ed606a9bdc56fd7c" +source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e6a2c0c#e6a2c0c2d23fefebc61ca300c14f5017d0c2f83f" [[package]] name = "bitty-ipc-auth" version = "0.0.1" -source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e9714e7#e9714e7f2a0ea243cdd2e209ed606a9bdc56fd7c" +source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e6a2c0c#e6a2c0c2d23fefebc61ca300c14f5017d0c2f83f" dependencies = [ "bitty-ipc-api", "getrandom 0.4.3", @@ -110,7 +109,7 @@ dependencies = [ [[package]] name = "bitty-ipc-core" version = "0.0.1" -source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e9714e7#e9714e7f2a0ea243cdd2e209ed606a9bdc56fd7c" +source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e6a2c0c#e6a2c0c2d23fefebc61ca300c14f5017d0c2f83f" dependencies = [ "bitty-ipc-api", ] @@ -118,7 +117,7 @@ dependencies = [ [[package]] name = "bitty-ipc-devtools" version = "0.0.1" -source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e9714e7#e9714e7f2a0ea243cdd2e209ed606a9bdc56fd7c" +source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e6a2c0c#e6a2c0c2d23fefebc61ca300c14f5017d0c2f83f" dependencies = [ "base16ct", "bitty-ipc-api", @@ -130,16 +129,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "bitty-ipc-mcp" -version = "0.0.1" -source = "git+https://github.com/bitty-terminal/bitty-ipc?rev=e9714e7#e9714e7f2a0ea243cdd2e209ed606a9bdc56fd7c" -dependencies = [ - "bitty-ipc-api", - "bitty-ipc-auth", - "bitty-ipc-core", -] - [[package]] name = "bitty-lua" version = "0.0.21" diff --git a/Cargo.toml b/Cargo.toml index 0cba615..fba8ba0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ all = { level = "warn", priority = -1 } # W-132 execution contract surface: argv/env validation and tracked-job # ceilings (`EnvPolicy`, `ExecutionRequest::validate`, # `MAX_TRACKED_EXECUTIONS`). Same exact rev the Core workspace uses. -bitty-ipc = { git = "https://github.com/bitty-terminal/bitty-ipc", rev = "e9714e7" } +bitty-ipc = { git = "https://github.com/bitty-terminal/bitty-ipc", rev = "e6a2c0c" } # Owned-tree / PTY boundary (`TreeBackend`, `OwnedTree`, `Pty*`) and the role # sandbox gate (`AgentRole`, `SandboxDecl`) live in Core crates that stay in # the `bitty` repository; the extension depends on them one-way (Core never diff --git a/README.md b/README.md index 868543d..d3f15a7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ Execution supervisor extension crate (landed CTX-0003 in PR #7, independently ve Read [AGENTS](AGENTS.md). Task management lives in CarryCtx. Prerequisite: W-132 / bitty-terminal-docs CTX-0088, Issue #171 (closed, contract accepted). Core retains PTY/process authority; principals, generations, cancellation, cgroups/OOM and recovery require approved contracts. +## Host spawn preparation + +`JobRegistry::with_spawn_preparer` installs a host-owned `SpawnPreparer` that +runs once per pipe job after the supervisor builds the argv-first, +closed-environment command and before it spawns. The returned guard stays +alive across `Command::spawn`, which is the contract `pre_exec`-based +confinement needs; a preparation error fails the job closed as `SpawnFailed` +with no process created. Core uses this seam to keep its Landlock exec +confinement (CTX-1151) while the supervisor owns job identity and lifetime. +PTY jobs are excluded: interactive PTY ownership stays with the terminal +primitive. + ## Delivery Local phases CTX-0001 -> CTX-0002 -> CTX-0003 -> CTX-0004 correspond to GitHub Issues #4 -> #3 -> #2 -> #1. CTX-0003 landed the supervisor mechanism with tests (PR #7, Closes #2) and CTX-0004 completed independent verification (Issue #1). Bootstrap (CTX-0001, Issue #4) is closed via PR #8; contract-readiness (CTX-0002, Issue #3) work is present and closes via this PR (retry of closed PR #9 after #4 landed). diff --git a/src/lib.rs b/src/lib.rs index f8129ba..1a4ff68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -191,7 +191,7 @@ pub use persistence::{ PERSIST_FORMAT_VERSION, }; pub use process_tree::{KillScope, ProcessTreeBackend}; -pub use registry::{DEFAULT_MAX_JOBS, JobRegistry, MAX_STORED_JOB_EVENTS}; +pub use registry::{DEFAULT_MAX_JOBS, JobRegistry, MAX_STORED_JOB_EVENTS, SpawnPreparer}; pub use retention::{MAX_RETENTION_TTL, RetentionError, RetentionPolicy, RetentionTier}; pub use sensitive_input::{ EchoState, InteractionClass, SecureInputDenial, automated_input_allowed, classify_with_verdict, diff --git a/src/registry.rs b/src/registry.rs index 6ffcbde..2c43a1e 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -13,6 +13,7 @@ //! Linux/macOS, Job Objects on Windows) and reports //! [`KillScope::DirectChild`] where none does (CTX-0512, CTX-0903). +use std::any::Any; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::io::Read; @@ -97,6 +98,40 @@ fn env_policy_is_sealed(policy: &EnvPolicy) -> bool { // ── registry ──────────────────────────────────────────────────────────────── +/// Host-owned preparation of the supervisor-built child command. +/// +/// The supervisor constructs the argv-first, closed-environment +/// [`std::process::Command`] itself. A host that must apply an inherited +/// OS confinement (for example Core's Landlock exec ruleset) or another +/// pre-spawn command configuration installs one preparer on the registry; +/// it runs exactly once per pipe job, after the command is built and before +/// `Command::spawn`. +/// +/// The returned guard is held by the supervisor until after the spawn +/// returns. That is the contract `pre_exec`-based confinement needs: the +/// guard owns the ruleset descriptor the child hook captured, and dropping +/// it before the fork would close the descriptor. A preparation error fails +/// the job closed as [`ExecutionOutcome::SpawnFailed`](crate::ExecutionOutcome) +/// with no process created. +/// +/// PTY jobs do not use this hook: interactive PTY ownership and its +/// environment inheritance stay with the accepted DEC-0017 terminal +/// contract. The hook is host authority, never a plugin surface. +pub trait SpawnPreparer: Send + Sync { + /// Applies host preparation to `command` before the supervisor spawns it. + /// + /// Returning `Err` refuses the spawn fail-closed and creates no process. + /// The returned guard stays alive across `Command::spawn`. + /// + /// # Errors + /// + /// Any host-defined refusal (unsupported platform, missing confinement + /// mechanism, invalid configuration) is returned as a bounded diagnostic + /// string. + fn prepare(&self, spec: &JobSpec, command: &mut Command) + -> Result, String>; +} + /// Bounded in-memory registry of supervised jobs. /// /// Cheap to clone: every clone shares the same table, event queue, and @@ -180,10 +215,24 @@ impl JobRegistry { events: DeliveryLog::new(), worker_spawner, cgroups: CgroupSource::NotConfigured, + spawn_preparer: None, })), } } + /// Installs the host-owned pre-spawn preparation hook. + /// + /// The preparer runs once per pipe job, after the supervisor builds the + /// closed-environment command and before it spawns. Its returned guard is + /// held across the spawn so `pre_exec`-based confinement descriptors stay + /// open. A preparation error fails the job closed; no process starts. + /// Cloneable registries share one preparer. + #[must_use] + pub fn with_spawn_preparer(self, preparer: Arc) -> Self { + lock_inner(&self.shared).spawn_preparer = Some(preparer); + self + } + /// Why per-job cgroup accounting is unavailable, when it was requested /// through [`JobRegistry::with_job_cgroups`] and could not be set up. #[must_use] @@ -1347,6 +1396,8 @@ struct RegistryInner { worker_spawner: WorkerSpawner, /// Where per-job cgroup leaves come from (CTX-0880). cgroups: CgroupSource, + /// Host-owned pre-spawn preparation (confinement hooks); one per registry. + spawn_preparer: Option>, } /// Starts one named worker thread for a job backend. @@ -1774,13 +1825,18 @@ fn supervise( // `write_input_as` either finds it (PTY) or fails closed (pipes), never // a stale half from a previous job: ids are never reused. let stdin_writer_slot = stdin_slot_handle(&shared, id); - let (worker_spawner, cgroups, generation) = { + let (worker_spawner, cgroups, generation, spawn_preparer) = { let inner = lock_inner(&shared); let generation = inner .jobs .get(&id) .map_or(0, |record| record.generation.get()); - (inner.worker_spawner, inner.cgroups.clone(), generation) + ( + inner.worker_spawner, + inner.cgroups.clone(), + generation, + inner.spawn_preparer.clone(), + ) }; // The leaf exists and its starting counter is read before the spawn; // the backend moves the leader in right after it (CTX-0880). @@ -1791,6 +1847,7 @@ fn supervise( output, stdin_writer_slot, worker_spawner, + spawn_preparer.as_deref(), &mut accounting, ) { Ok(backend) => backend, @@ -2204,11 +2261,19 @@ impl Backend { output: OutputSink, stdin_writer_slot: Option>>>, worker_spawner: WorkerSpawner, + spawn_preparer: Option<&dyn SpawnPreparer>, accounting: &mut JobAccounting, ) -> Result { match spec.io { - JobIo::Pipes => PipeJob::start_placed(spec, clock, output, worker_spawner, accounting) - .map(Self::Pipe), + JobIo::Pipes => PipeJob::start_placed( + spec, + clock, + output, + worker_spawner, + spawn_preparer, + accounting, + ) + .map(Self::Pipe), JobIo::Pty => PtyJob::start_placed( spec, clock, @@ -2350,7 +2415,7 @@ impl PipeJob { worker_spawner: WorkerSpawner, ) -> Result { let mut accounting = JobAccounting::prepare(CgroupSource::NotConfigured, 0, 0); - Self::start_placed(spec, clock, output, worker_spawner, &mut accounting) + Self::start_placed(spec, clock, output, worker_spawner, None, &mut accounting) } /// Starts the pipe job and moves the leader into the job's cgroup leaf @@ -2361,12 +2426,26 @@ impl PipeJob { clock: Arc, output: OutputSink, worker_spawner: WorkerSpawner, + spawn_preparer: Option<&dyn SpawnPreparer>, accounting: &mut JobAccounting, ) -> Result { let mut command = closed_pipe_command(&spec.program, &spec.args, spec.cwd.as_deref(), &spec.env); OwnedTree::prepare_command(&mut command); + // Host preparation runs after the tree/stdio shape is fixed and + // before the fork. The guard owns any `pre_exec`-captured descriptor + // and must outlive `Command::spawn`; an error refuses the spawn + // fail-closed with no process created. + let prepared = match spawn_preparer { + Some(preparer) => Some( + preparer + .prepare(spec, &mut command) + .map_err(StartFailure::before_spawn)?, + ), + None => None, + }; let mut child = command.spawn().map_err(StartFailure::before_spawn)?; + drop(prepared); let pid = child.id(); // Placement only writes the pid into the cgroup leaf (Linux); a // Windows child is still suspended here, which placement ignores. diff --git a/tests/job_spawn_preparer.rs b/tests/job_spawn_preparer.rs new file mode 100644 index 0000000..6244d6b --- /dev/null +++ b/tests/job_spawn_preparer.rs @@ -0,0 +1,201 @@ +//! CTX-0008: host-owned pre-spawn preparation over real pipe jobs. +//! +//! The host hook is the seam Core uses for inherited OS confinement +//! (CTX-1151). These tests pin the contract: the preparer runs exactly once, +//! its command configuration reaches the child, its guard outlives the spawn, +//! and a refusal fails the job closed before any process exists. + +use std::any::Any; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use bitty_execution::{ + ExecutionOutcome, JobId, JobRegistry, JobSnapshot, JobSpec, JobState, OutputStream, ReadOutput, + SpawnPreparer, +}; + +const HELPER_ENV: &str = "__BITTY_PREPARER_HELPER"; +const MARKER_ENV: &str = "__BITTY_PREPARER_MARKER"; +const PREPARED_ENV: &str = "__BITTY_PREPARER_VALUE"; + +/// Child entry point: selected by `HELPER_ENV`, a no-op in the parent suite. +#[test] +fn __bitty_preparer_helper_entry__() { + match std::env::var(HELPER_ENV).as_deref() { + Ok("report") => { + eprintln!( + "prepared={}", + std::env::var(PREPARED_ENV).unwrap_or_else(|_| "".to_owned()) + ); + } + Ok("marker") => { + if let Ok(path) = std::env::var(MARKER_ENV) { + let _ = std::fs::write(path, b"spawned"); + } + } + _ => {} + } +} + +fn helper_spec(mode: &str, env: Vec<(String, String)>) -> JobSpec { + let exe = std::env::current_exe() + .expect("test binary path") + .to_string_lossy() + .into_owned(); + JobSpec::new( + exe, + vec![ + "__bitty_preparer_helper_entry__".to_owned(), + "--exact".to_owned(), + "--nocapture".to_owned(), + ], + ) + .with_env( + bitty_ipc::execution::EnvPolicy::explicit( + std::iter::once((HELPER_ENV.to_owned(), mode.to_owned())) + .chain(env) + .collect(), + ) + .expect("explicit env"), + ) +} + +fn wait_terminal(registry: &JobRegistry, id: JobId) -> JobSnapshot { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let snapshot = registry.get(id).expect("job stays tracked"); + if snapshot.state.is_terminal() { + return snapshot; + } + assert!(Instant::now() < deadline, "job {id} did not finish in time"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +struct EnvPreparer { + calls: AtomicUsize, + guard_dropped: Arc, +} + +impl EnvPreparer { + fn new() -> Self { + Self { + calls: AtomicUsize::new(0), + guard_dropped: Arc::new(AtomicBool::new(false)), + } + } +} + +struct DropGuard(Arc); + +impl Drop for DropGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +impl SpawnPreparer for EnvPreparer { + fn prepare( + &self, + _spec: &JobSpec, + command: &mut std::process::Command, + ) -> Result, String> { + self.calls.fetch_add(1, Ordering::SeqCst); + command.env(PREPARED_ENV, "yes"); + Ok(Box::new(DropGuard(Arc::clone(&self.guard_dropped)))) + } +} + +#[test] +fn preparer_runs_once_and_its_guard_outlives_the_spawn() { + let preparer = Arc::new(EnvPreparer::new()); + let registry = JobRegistry::new().with_spawn_preparer(preparer.clone()); + let id = registry + .spawn(helper_spec("report", Vec::new())) + .expect("tracked"); + let snapshot = wait_terminal(®istry, id); + assert_eq!(snapshot.state, JobState::Done(ExecutionOutcome::Success)); + assert_eq!( + preparer.calls.load(Ordering::SeqCst), + 1, + "the preparer must run exactly once per job" + ); + assert!( + preparer.guard_dropped.load(Ordering::SeqCst), + "the preparation guard must be released after the spawn" + ); + let view = registry + .read_output(id, ReadOutput::new(OutputStream::Stderr)) + .expect("stderr retained"); + assert!( + view.text.contains("prepared=yes"), + "the preparer's command configuration must reach the child: {:?}", + view.text + ); +} + +struct RefusingPreparer; + +impl SpawnPreparer for RefusingPreparer { + fn prepare( + &self, + _spec: &JobSpec, + _command: &mut std::process::Command, + ) -> Result, String> { + Err("host preparation refused".to_owned()) + } +} + +fn marker_path() -> PathBuf { + std::env::temp_dir().join(format!( + "bitty-preparer-marker-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()) + )) +} + +#[test] +fn preparer_refusal_fails_closed_before_any_process() { + let marker = marker_path(); + let registry = JobRegistry::new().with_spawn_preparer(Arc::new(RefusingPreparer)); + let id = registry + .spawn(helper_spec( + "marker", + vec![(MARKER_ENV.to_owned(), marker.to_string_lossy().into_owned())], + )) + .expect("tracked job id"); + let snapshot = wait_terminal(®istry, id); + assert_eq!( + snapshot.state, + JobState::Done(ExecutionOutcome::SpawnFailed), + "a refused preparation is a terminal spawn failure" + ); + assert!( + !marker.exists(), + "no child may start when the preparer refuses: {}", + marker.display() + ); + let _ = std::fs::remove_file(&marker); +} + +#[test] +fn no_preparer_keeps_the_current_spawn_path() { + let registry = JobRegistry::new(); + let id = registry + .spawn(helper_spec("report", Vec::new())) + .expect("tracked"); + let snapshot = wait_terminal(®istry, id); + assert_eq!(snapshot.state, JobState::Done(ExecutionOutcome::Success)); + let view = registry + .read_output(id, ReadOutput::new(OutputStream::Stderr)) + .expect("stderr retained"); + assert!( + view.text.contains("prepared="), + "a registry without a preparer must not invent one: {:?}", + view.text + ); +}