From 075156843a86a4e05d698698812dccbf114fe474 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:24:13 +0900 Subject: [PATCH] Detect owned target exit during private-channel startup - When a contained runtime exits before its first private-channel frame, stop waiting once its exact target exits. - Pin the authenticated target at the start gate with a pidfd and keep conservative deadline behavior when exit evidence is unavailable. - Keep bounded diagnostics, ordinary terminal results and verified process-tree cleanup; incomplete handshakes remain failures. - Add early-exit, silent-target, unknown-identity and held-incarnation regressions. Validation: formatting passed locally on Linux; the new tests, all-target compilation, Clippy and the full platform suites run in this PR's CI. --- src/external_agent/gemini_bridge.rs | 29 ++++- src/process_runner.rs | 11 +- src/process_runner/part3.rs | 71 ++++++++++ src/process_runner/tests.rs | 194 ++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 7 deletions(-) diff --git a/src/external_agent/gemini_bridge.rs b/src/external_agent/gemini_bridge.rs index 569ba097..e2b829bd 100644 --- a/src/external_agent/gemini_bridge.rs +++ b/src/external_agent/gemini_bridge.rs @@ -1168,7 +1168,7 @@ pub(super) fn run( report } -#[cfg(all(target_os = "linux", test))] +#[cfg(target_os = "linux")] fn bootstrap_startup_category(stderr: &CapturedBytes) -> Option<&'static str> { if stderr.is_truncated() { return None; @@ -1184,7 +1184,7 @@ fn bootstrap_startup_category(stderr: &CapturedBytes) -> Option<&'static str> { } } -#[cfg(all(target_os = "linux", test))] +#[cfg(target_os = "linux")] fn bootstrap_startup_error(error: Option, stderr: &CapturedBytes) -> Option { let Some(category) = bootstrap_startup_category(stderr) else { return error; @@ -1787,7 +1787,9 @@ fn run_linux_online( report.side_effects = Some(output.process.side_effects); protocol.evidence.no_release_quiescent = !protocol.evidence.provider_released && output.process.safety_evidence_verified(); - if let Err(error) = output.interaction { + if let Some(error) = + bootstrap_startup_error(output.interaction.err(), &output.process.stderr) + { record_external_error(report, error); } if output.process.timed_out { @@ -2940,14 +2942,31 @@ mod tests { let held = run .gemini_bridge_evidence() .with_context(|| format!("no held bridge evidence: {:?}", run.error))?; - assert!(held.ready, "{:?}", run.error); assert!(held.no_release_quiescent, "{:?}", run.error); assert!(!held.provider_released); assert_eq!(held.tokens, None); assert!(run.scratch_quiescence_verified()); assert!(!run.succeeded()); assert!(!run.publishable); - assert_eq!(run.exit_code, Some(0)); + if held.ready { + assert_eq!(run.exit_code, Some(0)); + } else { + // An installed bootstrap may reject its profile before hello. That is a + // prompt, quiescent startup failure, never a runtime qualification. + assert!(!run.timed_out); + assert!(started.elapsed() < command.timeout); + assert!(run.exit_code.is_some_and(|code| code != 0)); + let error = run.error.as_deref().context("missing startup failure")?; + assert!( + error + .contains("contained private-channel target exited before protocol completion"), + "{error}" + ); + assert!( + error.contains("Gemini bootstrap startup: pre_handshake_"), + "{error}" + ); + } let restored: ExternalAgentRun = serde_json::from_slice(&serde_json::to_vec(&run)?)?; assert!(restored.gemini_bridge_evidence().is_none()); Ok(()) diff --git a/src/process_runner.rs b/src/process_runner.rs index 28373f81..49e76af6 100644 --- a/src/process_runner.rs +++ b/src/process_runner.rs @@ -3740,7 +3740,11 @@ fn run_process_cancellable_with_interaction( .target_pid(&mut child, operation_deadline, cancellation) .and_then(|pid| { crate::agent_lifecycle::process_start_time(pid) - .map(|start| (pid, start, unit.cgroup_path.clone())) + .map(|start| { + let watch = + PrivateTargetExitWatch::capture(pid, &start, &unit.cgroup_path); + ((pid, start, unit.cgroup_path.clone()), watch) + }) .map_err(|error| std::io::Error::other(error.to_string())) }), _ => Err(std::io::Error::other( @@ -3813,7 +3817,10 @@ fn run_process_cancellable_with_interaction( // Capture the guardian-published target identity while the target is still held // at the start gate. Waiting after release can consume the operation deadline // before the private-channel handler starts. - session.peer_binding = private_peer_binding; + if let Some((binding, watch)) = private_peer_binding { + session.peer_binding = Some(binding); + session.private_target = watch; + } } if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(&mut session))) .is_err() diff --git a/src/process_runner/part3.rs b/src/process_runner/part3.rs index e34cf0fc..2870f629 100644 --- a/src/process_runner/part3.rs +++ b/src/process_runner/part3.rs @@ -3083,6 +3083,8 @@ impl PreparedChildIo { Ok(ContainedProcessSession { #[cfg(target_os = "linux")] peer_binding: None, + #[cfg(target_os = "linux")] + private_target: None, label: label.to_string(), cancellation, operation_deadline, @@ -3129,6 +3131,8 @@ impl ContainedProcessStartGate<'_> { pub(crate) struct ContainedProcessSession<'a> { #[cfg(target_os = "linux")] peer_binding: Option<(u32, String, PathBuf)>, + #[cfg(target_os = "linux")] + private_target: Option, label: String, cancellation: &'a ProcessCancellation, operation_deadline: Option, @@ -3141,6 +3145,62 @@ pub(crate) struct ContainedProcessSession<'a> { output_drainers: OutputDrainers, } +#[cfg(target_os = "linux")] +#[derive(Debug, PartialEq, Eq)] +enum PrivateTargetState { + Alive, + Exited, + Unknown, +} + +#[cfg(target_os = "linux")] +struct PrivateTargetExitWatch(std::os::fd::OwnedFd); + +#[cfg(target_os = "linux")] +impl PrivateTargetExitWatch { + // Called while the guardian still holds its authenticated target at the start gate. + // Failure to pin that incarnation leaves exit evidence unknown, never confirmed. + fn capture(pid: u32, start: &str, cgroup: &Path) -> Option { + use std::os::fd::FromRawFd; + + let pid = libc::pid_t::try_from(pid).ok().filter(|pid| *pid > 0)?; + // SAFETY: pidfd_open consumes a positive PID and zero flags, without pointers. + let descriptor = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0_u32) }; + if descriptor < 0 { + return None; + } + // SAFETY: a successful pidfd_open returns a fresh owned descriptor. + let descriptor = unsafe { std::os::fd::OwnedFd::from_raw_fd(descriptor as i32) }; + let same_start = crate::agent_lifecycle::process_start_time(pid as u32) + .is_ok_and(|observed| observed == start); + let member = fs::read_to_string(cgroup.join("cgroup.procs")) + .is_ok_and(|members| members.lines().any(|line| line.trim() == pid.to_string())); + (same_start && member).then_some(Self(descriptor)) + } + + fn observe(&self) -> PrivateTargetState { + use std::os::fd::AsRawFd; + + let mut descriptor = libc::pollfd { + fd: self.0.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: poll borrows one initialized descriptor; timeout zero never waits. + let result = unsafe { libc::poll(&mut descriptor, 1, 0) }; + if result < 0 || descriptor.revents & (libc::POLLERR | libc::POLLNVAL) != 0 { + PrivateTargetState::Unknown + } else if descriptor.revents & libc::POLLIN != 0 { + // This handle stays bound to the captured incarnation even after PID reuse. + PrivateTargetState::Exited + } else if result == 0 { + PrivateTargetState::Alive + } else { + PrivateTargetState::Unknown + } + } +} + #[cfg(target_os = "linux")] fn private_peer_start_refusal( expected: &str, @@ -3221,9 +3281,20 @@ impl ContainedProcessSession<'_> { pub(crate) fn check_private_channel_live(&mut self) -> Result<(), String> { self.ensure_interactive_live()?; self.output_drainers.drain_ready(); + #[cfg(target_os = "linux")] + if self.private_target_state() == PrivateTargetState::Exited { + return self.fail_io("contained private-channel target exited before protocol completion"); + } Ok(()) } + #[cfg(target_os = "linux")] + fn private_target_state(&self) -> PrivateTargetState { + self.private_target + .as_ref() + .map_or(PrivateTargetState::Unknown, PrivateTargetExitWatch::observe) + } + pub(crate) fn receive_line( &mut self, wait: Duration, diff --git a/src/process_runner/tests.rs b/src/process_runner/tests.rs index b5280b10..afbc97fe 100644 --- a/src/process_runner/tests.rs +++ b/src/process_runner/tests.rs @@ -685,6 +685,199 @@ fn verified_contained_interactive_session_proves_tree_and_side_effects() { assert!(result.process.safety_evidence_verified()); } +#[cfg(target_os = "linux")] +#[test] +fn private_channel_reports_owned_target_exit_before_deadline() { + let temp = tempfile::tempdir().expect("tempdir"); + let spec = private_channel_fixture( + temp.path(), + "printf '%s\\n' 'bootstrap: pre_handshake_profile' 'credential-secret' >&2; exit 23", + ); + let unit_capture = TestSystemdUnitNameCapture::start(); + let result = run_process_interactive(spec, &ProcessCancellation::new(), |session| { + assert!( + session.private_target.is_some(), + "target must be pinned before release" + ); + let started = Instant::now(); + loop { + if let Err(error) = session.check_private_channel_live() { + assert!(started.elapsed() < Duration::from_secs(5)); + return Err::<(), _>(error); + } + thread::sleep(Duration::from_millis(2)); + } + }); + let unit_names = unit_capture.finish(); + let result = match result { + Ok(result) => result, + Err(error) if is_verified_backend_unavailable(&error) => { + report_verified_backend_unavailable_skip(&error, &unit_names); + return; + } + Err(error) => panic!("private-channel runner failed: {error:?}"), + }; + let error = result.interaction.unwrap_err(); + assert_eq!( + error, + "contained private-channel target exited before protocol completion" + ); + assert_eq!(result.process.stdin_error.as_deref(), Some(error.as_str())); + assert_eq!( + result.process.status.and_then(|status| status.code()), + Some(23) + ); + assert!(!result.process.timed_out); + assert!(result.process.safety_evidence_verified()); + assert!(String::from_utf8_lossy(result.process.stderr.as_bytes()) + .contains("bootstrap: pre_handshake_profile")); + assert!(!error.contains("credential-secret")); + assert_systemd_units_have_no_residue(&unit_names); +} + +#[cfg(target_os = "linux")] +#[test] +fn private_channel_live_silent_target_keeps_deadline() { + let temp = tempfile::tempdir().expect("tempdir"); + let spec = private_channel_fixture(temp.path(), "read -r _"); + let unit_capture = TestSystemdUnitNameCapture::start(); + let result = run_process_interactive( + spec, + &ProcessCancellation::new(), + |session| -> Result<(), String> { + assert_eq!(session.private_target_state(), PrivateTargetState::Alive); + loop { + session.check_private_channel_live()?; + thread::sleep(Duration::from_millis(2)); + } + }, + ); + let unit_names = unit_capture.finish(); + let result = match result { + Ok(result) => result, + Err(error) if is_verified_backend_unavailable(&error) => { + report_verified_backend_unavailable_skip(&error, &unit_names); + return; + } + Err(error) => panic!("private-channel runner failed: {error:?}"), + }; + assert_eq!( + result.interaction.unwrap_err(), + "contained interactive session reached its operation deadline" + ); + assert!(result.process.timed_out); + assert!(result.process.safety_evidence_verified()); + assert_systemd_units_have_no_residue(&unit_names); +} + +#[cfg(target_os = "linux")] +fn private_channel_fixture(root: &Path, script: &str) -> ProcessSpec { + let workspace = root.join("workspace"); + let control = root.join("control"); + let profile = control.join("profile"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&profile).unwrap(); + ProcessSpec::shell( + "private-channel target fixture", + Shell::UnixSh, + script, + &workspace, + 1024, + ) + .with_stdin_limit(64) + .with_timeout(Some(CONTENTION_RESILIENT_PROCESS_TEST_TIMEOUT)) + .with_side_effect_confinement(SideEffectConfinementProfile::GeminiOfflineBridge( + GeminiOfflineBridgeProfile::new(&workspace, &control, &profile), + )) +} + +#[cfg(target_os = "linux")] +#[test] +fn private_channel_exit_watch_is_bound_to_the_held_incarnation() { + let temp = tempfile::tempdir().unwrap(); + let mut child = Command::new("/bin/sh") + .args(["-c", "read -r _; exit 19"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let pid = child.id(); + let start = crate::agent_lifecycle::process_start_time(pid).unwrap(); + // Controlled membership fixture; containment is exercised separately above. + fs::write(temp.path().join("cgroup.procs"), format!("{pid}\n")).unwrap(); + let watch = PrivateTargetExitWatch::capture(pid, &start, temp.path()); + let before = watch.as_ref().map(PrivateTargetExitWatch::observe); + drop(child.stdin.take()); + let status = child.wait().unwrap(); + assert_eq!(status.code(), Some(19)); + assert_eq!(before, Some(PrivateTargetState::Alive)); + // Reaping and rebinding the membership path cannot retarget the held descriptor. + fs::write( + temp.path().join("cgroup.procs"), + format!("{}\n", std::process::id()), + ) + .unwrap(); + assert_eq!(watch.unwrap().observe(), PrivateTargetState::Exited); + assert!(PrivateTargetExitWatch::capture(std::process::id(), &start, temp.path()).is_none()); +} + +#[cfg(target_os = "linux")] +#[test] +fn private_channel_unknown_target_never_claims_exit() { + let pid = std::process::id(); + let start = crate::agent_lifecycle::process_start_time(pid).unwrap(); + let temp = tempfile::tempdir().unwrap(); + let cancellation = ProcessCancellation::new(); + let empty_reader = |stream| PipeReader { + stream, + receiver: None, + thread: None, + tee_helper: None, + capture: BoundedBuffer::new(64), + complete: true, + error: None, + }; + let mut session = ContainedProcessSession { + peer_binding: Some((pid, start.clone(), temp.path().to_path_buf())), + private_target: None, + label: "private-channel unknown target fixture".into(), + cancellation: &cancellation, + operation_deadline: None, + stdin: None, + stdin_bytes_written: 0, + max_stdin_bytes: 64, + pending_stdout: Vec::new(), + stdout_eof: true, + io_error: None, + output_drainers: OutputDrainers { + stdout: empty_reader("stdout"), + stderr: empty_reader("stderr"), + label: "private-channel unknown target fixture".into(), + }, + }; + // Neither EOF nor unavailable cgroup evidence establishes the captured target's exit. + assert!(PrivateTargetExitWatch::capture(pid, &start, temp.path()).is_none()); + assert_eq!(session.private_target_state(), PrivateTargetState::Unknown); + assert!(session.check_private_channel_live().is_ok()); + fs::write(temp.path().join("cgroup.procs"), format!("{pid}\n")).unwrap(); + // A rebound PID must not acquire a watch for a different start-time identity. + assert!(PrivateTargetExitWatch::capture(pid, "private-identity-secret", temp.path()).is_none()); + assert!(PrivateTargetExitWatch::capture(0, &start, temp.path()).is_none()); + assert!(session.check_private_channel_live().is_ok()); + assert!(session.io_error.is_none()); + session.operation_deadline = Some(Instant::now()); + assert_eq!( + session.check_private_channel_live().unwrap_err(), + "contained interactive session reached its operation deadline" + ); + cancellation.cancel(); + assert_eq!( + session.check_private_channel_live().unwrap_err(), + "contained interactive session was cancelled" + ); +} + #[cfg(target_os = "linux")] #[test] fn private_peer_identity_refusal_is_fixed_and_preserves_guard_order() { @@ -708,6 +901,7 @@ fn private_peer_identity_refusal_is_fixed_and_preserves_guard_order() { // This exercises the peer guard directly, not launched-child containment. let session = |peer_binding| ContainedProcessSession { peer_binding, + private_target: None, label: "private peer guard fixture".into(), cancellation: &cancellation, operation_deadline: None,