Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/external_agent/gemini_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String>, stderr: &CapturedBytes) -> Option<String> {
let Some(category) = bootstrap_startup_category(stderr) else {
return error;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
Expand Down
11 changes: 9 additions & 2 deletions src/process_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
71 changes: 71 additions & 0 deletions src/process_runner/part3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<PrivateTargetExitWatch>,
label: String,
cancellation: &'a ProcessCancellation,
operation_deadline: Option<Instant>,
Expand All @@ -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<Self> {
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,
Expand Down Expand Up @@ -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,
Expand Down
194 changes: 194 additions & 0 deletions src/process_runner/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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,
Expand Down
Loading