From d68d50aac4257a1f9ac821cbc76586d491152e30 Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:56:59 +0900 Subject: [PATCH 1/3] Bind Grok launches to the selected-account lifecycle Stacked on #776 (closed launch lifecycle custody); it is retargeted to main once #776 merges. Second slice of #725. - Bind Grok runs to the selected-account launch lifecycle before the adapter declaration and credential capability are constructed. - Carry admission expiry through process setup and record reference-only lifecycle metadata. - Finalize from confirmed runner outcomes; setup timeouts and tee conflicts are pre-release refusals that release the account, while an unknown child exit keeps custody. - Hold the outer selected-use lease through report teardown. Tests use synthetic material and deterministic child barriers only. Other root providers and the optional catalog lifecycle integration follow separately. Validation: formatting and 18 exact Linux tests passed; Clippy and the full suites run in CI. --- .../core/src/providers/launch_lifecycle.rs | 17 +- .../providers/launch_lifecycle/external.rs | 172 ++++++ account-manager/docs/MACO_INTEGRATION.md | 23 +- src/account_authority/grok.rs | 129 ++++- src/external_agent.rs | 175 ++++-- .../executor/launch_lifecycle.rs | 109 ++++ .../executor/launch_lifecycle/tests.rs | 531 ++++++++++++++++++ src/external_agent/executor/mod.rs | 1 + src/external_agent/tests.rs | 69 ++- src/secret_lifecycle.rs | 9 +- src/secret_lifecycle/provider_dispatch.rs | 33 ++ 11 files changed, 1193 insertions(+), 75 deletions(-) create mode 100644 account-manager/core/src/providers/launch_lifecycle/external.rs create mode 100644 src/external_agent/executor/launch_lifecycle.rs create mode 100644 src/external_agent/executor/launch_lifecycle/tests.rs create mode 100644 src/secret_lifecycle/provider_dispatch.rs diff --git a/account-manager/core/src/providers/launch_lifecycle.rs b/account-manager/core/src/providers/launch_lifecycle.rs index c145a7c0..0b188b59 100644 --- a/account-manager/core/src/providers/launch_lifecycle.rs +++ b/account-manager/core/src/providers/launch_lifecycle.rs @@ -17,8 +17,11 @@ use super::{ launch_spec_for, spawn_command, spawn_launch_with, LaunchEnvironment, ProviderAdapter, }; +mod external; +pub use external::ExternalLaunchCustody; + /// Closed finalization reasons. An unconfirmed exit is never a finalization. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum LaunchFinalization { Refused, SpawnFailed, @@ -27,7 +30,7 @@ pub enum LaunchFinalization { Cancelled, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum LaunchPhase { Prepared, Constructing, @@ -37,7 +40,7 @@ pub enum LaunchPhase { } /// Names and sources only; no inherited or resolved environment values. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum CredentialEnvironmentInput { SelectedCredential(String), Removed(String), @@ -62,6 +65,14 @@ pub struct LaunchLifecycleView { #[derive(Clone)] pub struct LaunchLifecycleHandle(Arc>); +impl PartialEq for LaunchLifecycleHandle { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for LaunchLifecycleHandle {} + impl LaunchLifecycleHandle { pub fn snapshot(&self) -> LaunchLifecycleView { self.0.lock().expect("launch lifecycle lock").clone() diff --git a/account-manager/core/src/providers/launch_lifecycle/external.rs b/account-manager/core/src/providers/launch_lifecycle/external.rs new file mode 100644 index 00000000..8e31ea56 --- /dev/null +++ b/account-manager/core/src/providers/launch_lifecycle/external.rs @@ -0,0 +1,172 @@ +//! Selected-use custody for synchronous external process runners. + +use crate::account_authority::SelectedUseLease; + +use super::*; +use crate::providers::LaunchSpec; + +/// A declared launch whose process is owned by an external runner. That runner +/// must confirm the entire owned process tree is empty before finalization. +/// Dropping unknown custody retains the selected-use barrier until process exit. +#[must_use = "external launch custody must be settled from live process evidence"] +pub struct ExternalLaunchCustody { + lifecycle: LaunchLifecycle, + lease: Option, +} + +impl LaunchLifecycle { + /// Adopt an adapter declaration under a fresh exact-selection lease. Resolve + /// vendor-home capabilities only in `construct`, while admission is pending. + /// Credential-store launches use [`Self::spawn`] instead. + pub fn prepare_external>( + self, + registry: &StoredAccountRegistry, + adapter: &dyn ProviderAdapter, + construct: impl FnOnce(&LaunchSpec) -> std::result::Result, + ) -> std::result::Result<(ExternalLaunchCustody, T), E> { + let binding = { + let mut view = self.handle.0.lock().expect("launch lifecycle lock"); + self.check_admission(&view)?; + view.phase = LaunchPhase::Constructing; + view.binding.clone() + }; + if binding.provider_id != adapter.id() { + return Err(Error::UnknownProvider(binding.provider_id).into()); + } + let lease = registry.acquire_selected_use(&binding)?; + let account = registry.complete(&binding.provider_id, &binding.account_id)?; + let spec = launch_spec_for(adapter, &account)?; + if spec.requires_credential() { + return Err(refused("external runner requires a vendor-home declaration").into()); + } + { + let mut view = self.handle.0.lock().expect("launch lifecycle lock"); + self.check_admission(&view)?; + view.environment = spec + .environment + .iter() + .filter_map(|entry| match entry { + LaunchEnvironment::Remove { name } => { + Some(CredentialEnvironmentInput::Removed(name.clone())) + } + _ => None, + }) + .collect(); + } + let material = construct(&spec)?; + self.check_admission(&self.handle.0.lock().expect("launch lifecycle lock"))?; + Ok(( + ExternalLaunchCustody { + lifecycle: self, + lease: Some(lease), + }, + material, + )) + } +} + +impl ExternalLaunchCustody { + pub fn lifecycle(&self) -> LaunchLifecycleHandle { + self.lifecycle.handle() + } + + /// Open an external credential capability while construction is pending. + /// Revocation or expiry during construction refuses the resulting material. + pub fn construct>( + &self, + construct: impl FnOnce() -> std::result::Result, + ) -> std::result::Result { + { + let view = self + .lifecycle + .handle + .0 + .lock() + .expect("launch lifecycle lock"); + if view.phase != LaunchPhase::Constructing { + return Err(refused("external launch construction is no longer pending").into()); + } + self.lifecycle.check_admission(&view)?; + } + let material = construct()?; + self.lifecycle.check_admission( + &self + .lifecycle + .handle + .0 + .lock() + .expect("launch lifecycle lock"), + )?; + Ok(material) + } + + /// Admission and revocation serialize before the runner can release a child. + pub fn begin_spawn(&mut self) -> Result<()> { + let mut view = self + .lifecycle + .handle + .0 + .lock() + .expect("launch lifecycle lock"); + if view.phase != LaunchPhase::Constructing { + return Err(refused("external launch is not pending construction")); + } + self.lifecycle.check_admission(&view)?; + view.phase = LaunchPhase::Running; + Ok(()) + } + + /// The caller has live evidence that no child was released, or that the + /// complete owned tree has exited. Unknown exit must use `exit_unknown`. + pub fn finalize(&mut self, reason: LaunchFinalization) { + let mut view = self + .lifecycle + .handle + .0 + .lock() + .expect("launch lifecycle lock"); + let allowed = match reason { + LaunchFinalization::Refused => { + matches!(view.phase, LaunchPhase::Constructing | LaunchPhase::Running) + } + LaunchFinalization::SpawnFailed => view.phase == LaunchPhase::Running, + _ => matches!(view.phase, LaunchPhase::Running | LaunchPhase::ExitUnknown), + }; + if allowed { + view.phase = LaunchPhase::Finalized(reason); + self.lease.take(); + } + } + + /// Also applies to an unconfirmed preflight child during construction. + pub fn exit_unknown(&mut self) { + let mut view = self + .lifecycle + .handle + .0 + .lock() + .expect("launch lifecycle lock"); + if !matches!(view.phase, LaunchPhase::Finalized(_)) { + view.phase = LaunchPhase::ExitUnknown; + } + } +} + +impl Drop for ExternalLaunchCustody { + fn drop(&mut self) { + let mut view = self + .lifecycle + .handle + .0 + .lock() + .expect("launch lifecycle lock"); + if matches!(view.phase, LaunchPhase::Running | LaunchPhase::ExitUnknown) { + view.phase = LaunchPhase::ExitUnknown; + if let Some(lease) = self.lease.take() { + // An external runner failed to prove exit. Releasing this lease + // would permit replacing credentials still reachable by a child. + std::mem::forget(lease); + } + } + } +} diff --git a/account-manager/docs/MACO_INTEGRATION.md b/account-manager/docs/MACO_INTEGRATION.md index c4a5c26c..c0258ab2 100644 --- a/account-manager/docs/MACO_INTEGRATION.md +++ b/account-manager/docs/MACO_INTEGRATION.md @@ -218,9 +218,26 @@ finalize once. `LeasedChild::cancel` kills only its owned child and waits for exit. Wait failure or abandoned custody with unknown exit retains the use lease and reports `ExitUnknown`, without claiming cancellation or finalization. The existing account mutation barrier continues to refuse replacement during use. -Coordinated credential rotation/revocation, durable recovery, remote operation -dispatch, and the root external executor bridge remain separate integration -work; this in-process guard does not implement those capabilities. +The root Grok external executor transfers that lease to the same closed operation +without a gap. It opens the selected vendor-home capability during guarded +construction, checks expiry/revocation before runner dispatch, and records only +run/account identity, inherited-input names, and lifecycle state in public run +metadata. A confirmed empty process tree is required to finalize normal/nonzero +exit or cancellation; unconfirmed preflight or target cleanup retains custody. +Setup deadline exhaustion and tee conflicts are confirmed pre-release refusals; +they do not create unknown-child custody or erase an earlier uncertain child. +The outer selected-use lease also remains held until launch-authority teardown, +covering retained credential descriptors and later reporting after child exit. +Restored metadata cannot recreate live custody or authorize finalization. + +Active-child rotation or revocation must use the existing cancellation path and +wait for confirmed exit before replacing/deleting the account incarnation. An +admission revoke cannot cancel an active child. The account mutation barrier +refuses replacement while that child or an unknown-exit lease remains active. +This does not introduce a credential rotation service. Durable recovery, remote +operation dispatch, and equivalent root-executor lifecycle integration for the +other provider-specific account paths remain separate work. Synthetic fixtures +do not qualify a real provider account or replace provider-specific acceptance. The initial operation kind is `work-proposal`: one fresh proposal turn, with no provider-owned workspace mutation, nested orchestration, or independent review. diff --git a/src/account_authority/grok.rs b/src/account_authority/grok.rs index 3f7006c6..28f2e7b3 100644 --- a/src/account_authority/grok.rs +++ b/src/account_authority/grok.rs @@ -11,6 +11,9 @@ use coding_agent_manager_lib::account_authority::StoredAccountRegistry; use coding_agent_manager_lib::error::Error as CamError; use coding_agent_manager_lib::paths::{project_dirs, stored_accounts_path}; use coding_agent_manager_lib::providers::grok_cli::GrokCliAdapter; +use coding_agent_manager_lib::providers::launch_lifecycle::{ + ExternalLaunchCustody, LaunchLifecycle, LaunchLifecycleHandle, +}; use coding_agent_manager_lib::providers::launch_spec_for; pub(crate) use super::GROK_CLI_PROVIDER_ID; @@ -26,12 +29,35 @@ pub(crate) struct GrokLaunchAuthority { authority_id: Option, socket_path: Option, // Held until this launch authority is dropped, including failed runs. - _selected_use_lease: SelectedUseLease, + _selected_use_lease: Option, + launch_custody: Option>, + launch_lifecycle: Option, managed_grok_home: PathBuf, launch_env_removals: Vec, } impl GrokLaunchAuthority { + /// Open the credential capability only inside guarded construction. + pub(crate) fn construct_material( + &self, + construct: impl FnOnce(&Path) -> Result, + ) -> Result { + let custody = self + .launch_custody() + .context("Grok launch admission is missing")?; + custody.construct(|| construct(&self.managed_grok_home)) + } + + pub(crate) fn launch_custody(&self) -> Option> { + self.launch_custody + .as_ref() + .map(std::cell::RefCell::borrow_mut) + } + + pub(crate) fn launch_lifecycle(&self) -> Option { + self.launch_lifecycle.clone() + } + #[cfg(test)] pub(crate) fn binding(&self) -> &SelectedAccountBinding { &self.binding @@ -119,6 +145,38 @@ impl GrokLaunchAuthority { /// process observation state is not consulted at launch. pub(crate) fn acquire_grok_launch_authority( carried: Option<&FrozenGrokSelectedBinding>, +) -> Result { + acquire_grok_launch_authority_inner(carried, None) +} + +struct GrokLaunchAdmission<'a> { + run_id: &'a str, + operation_id: &'a str, + deadline: std::time::Instant, + handle: &'a mut Option, +} + +pub(crate) fn acquire_guarded_grok_launch_authority( + carried: Option<&FrozenGrokSelectedBinding>, + run_id: &str, + operation_id: &str, + deadline: std::time::Instant, + handle: &mut Option, +) -> Result { + acquire_grok_launch_authority_inner( + carried, + Some(&mut GrokLaunchAdmission { + run_id, + operation_id, + deadline, + handle, + }), + ) +} + +fn acquire_grok_launch_authority_inner( + carried: Option<&FrozenGrokSelectedBinding>, + admission: Option<&mut GrokLaunchAdmission<'_>>, ) -> Result { let socket_path = super::cam_socket_for_frozen_selection( carried.and_then(|frozen| frozen.authority_socket.as_deref()), @@ -131,9 +189,9 @@ pub(crate) fn acquire_grok_launch_authority( ) })?; revalidate_frozen_selection_via_socket(socket_path, frozen)?; - return acquire_from_execution_registry(Some(frozen), Some(socket_path.clone())); + return acquire_from_execution_registry(Some(frozen), Some(socket_path.clone()), admission); } - acquire_from_execution_registry(frozen, socket_path) + acquire_from_execution_registry(frozen, socket_path, admission) } /// Acquire a selected CAM account for optional live catalog discovery. @@ -209,7 +267,10 @@ fn revalidate_frozen_selection_via_socket( fn acquire_from_execution_registry( frozen: Option<&FrozenGrokSelectedBinding>, socket_path: Option, + admission: Option<&mut GrokLaunchAdmission<'_>>, ) -> Result { + #[cfg(test)] + let mut admission = admission; #[cfg(test)] { let override_result = CAM_GROK_TEST_OVERRIDE.with(|cell| { @@ -219,6 +280,7 @@ fn acquire_from_execution_registry( &harness.adapter, frozen, socket_path.clone(), + admission.as_deref_mut(), ) }) }); @@ -237,6 +299,7 @@ fn acquire_from_execution_registry( &GrokCliAdapter::default(), frozen, socket_path, + admission, ) } @@ -246,7 +309,7 @@ pub(crate) fn acquire_grok_launch_authority_from( registry: &StoredAccountRegistry, adapter: &GrokCliAdapter, ) -> Result { - acquire_grok_launch_authority_from_bound(registry, adapter, None, None) + acquire_grok_launch_authority_from_bound(registry, adapter, None, None, None) } fn acquire_grok_launch_authority_from_bound( @@ -254,6 +317,7 @@ fn acquire_grok_launch_authority_from_bound( adapter: &GrokCliAdapter, frozen: Option<&FrozenGrokSelectedBinding>, socket_path: Option, + admission: Option<&mut GrokLaunchAdmission<'_>>, ) -> Result { let current = registry .selected_binding(GROK_CLI_PROVIDER_ID) @@ -269,7 +333,14 @@ fn acquire_grok_launch_authority_from_bound( } else { (current, None) }; - finish_grok_launch_authority(registry, adapter, binding, authority_id, socket_path) + finish_grok_launch_authority( + registry, + adapter, + binding, + authority_id, + socket_path, + admission, + ) } fn require_execution_matches_frozen( @@ -297,23 +368,54 @@ fn finish_grok_launch_authority( binding: SelectedAccountBinding, authority_id: Option, socket_path: Option, + admission: Option<&mut GrokLaunchAdmission<'_>>, ) -> Result { let selected_use_lease = registry .acquire_selected_use(&binding) .map_err(map_cam_error)?; - let account = registry - .complete(GROK_CLI_PROVIDER_ID, &binding.account_id) - .map_err(map_cam_error)?; - let launch_spec = launch_spec_for(adapter, &account).map_err(map_cam_error)?; - let managed_grok_home = grok_home_from_launch_spec(&launch_spec) - .context("Coding Agent Manager Grok launch spec did not declare GROK_HOME")?; - let launch_env_removals = launch_spec.environment_removals(); + let (managed_grok_home, launch_env_removals, launch_custody, launch_lifecycle) = + if let Some(admission) = admission { + let guard = LaunchLifecycle::new( + admission.run_id, + admission.operation_id, + binding.clone(), + Some(admission.deadline), + )?; + let handle = guard.handle(); + *admission.handle = Some(handle.clone()); + let (custody, (home, removals)) = + guard.prepare_external(registry, adapter, |spec| { + Ok::<_, anyhow::Error>(( + grok_home_from_launch_spec(spec)?, + spec.environment_removals(), + )) + })?; + ( + home, + removals, + Some(std::cell::RefCell::new(custody)), + Some(handle), + ) + } else { + let account = registry + .complete(GROK_CLI_PROVIDER_ID, &binding.account_id) + .map_err(map_cam_error)?; + let spec = launch_spec_for(adapter, &account).map_err(map_cam_error)?; + ( + grok_home_from_launch_spec(&spec)?, + spec.environment_removals(), + None, + None, + ) + }; Ok(GrokLaunchAuthority { registry: StoredAccountRegistry::new(registry.metadata_path()), binding, authority_id, socket_path, - _selected_use_lease: selected_use_lease, + _selected_use_lease: Some(selected_use_lease), + launch_custody, + launch_lifecycle, managed_grok_home, launch_env_removals, }) @@ -1125,6 +1227,7 @@ mod tests { &harness.adapter, None, socket_path, + None, ) }, |authority| authority.verify_binding_unchanged(), diff --git a/src/external_agent.rs b/src/external_agent.rs index 163aa8c8..e8639ac7 100644 --- a/src/external_agent.rs +++ b/src/external_agent.rs @@ -65,6 +65,9 @@ use crate::secure_output::{ReservedOutputFile, SecureOutputRoot}; use crate::supervise::AgentRole; use crate::worktree::normalize_agent_id; use anyhow::{bail, Context, Result}; +use coding_agent_manager_lib::providers::launch_lifecycle::{ + ExternalLaunchCustody, LaunchLifecycleHandle, +}; use git2::Oid; use serde::{Deserialize, Serialize}; #[cfg(target_os = "linux")] @@ -4724,6 +4727,17 @@ impl std::fmt::Debug for RedactedByteCount { } impl ExternalAgentRun { + pub fn provider_launch_lifecycle( + &self, + ) -> Option { + self.stdout + .run_metadata + .launch_lifecycle_handle + .as_ref() + .map(|handle| handle.snapshot().into()) + .or_else(|| self.stdout.run_metadata.launch_lifecycle_metadata.clone()) + } + pub fn environment_preflight_results(&self) -> &[EnvironmentPreflightResult] { &self.stdout.run_metadata.environment_preflight_results } @@ -5287,6 +5301,8 @@ struct ExternalAgentRunWireRef<'a> { managed_gemini_selection: &'a Option, #[serde(default, skip_serializing_if = "Option::is_none")] managed_cursor_selection: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + provider_launch_lifecycle: Option, } #[derive(Deserialize)] @@ -5342,6 +5358,8 @@ struct ExternalAgentRunWireOwned { managed_gemini_selection: Option, #[serde(default)] managed_cursor_selection: Option, + #[serde(default)] + provider_launch_lifecycle: Option, } impl Serialize for ExternalAgentRun { @@ -5389,6 +5407,7 @@ impl Serialize for ExternalAgentRun { managed_codex_selection: &self.stdout.run_metadata.managed_codex_selection, managed_gemini_selection: &self.stdout.run_metadata.managed_gemini_selection, managed_cursor_selection: &self.stdout.run_metadata.managed_cursor_selection, + provider_launch_lifecycle: self.provider_launch_lifecycle(), } .serialize(serializer) } @@ -5418,6 +5437,7 @@ impl<'de> Deserialize<'de> for ExternalAgentRun { stdout.run_metadata.managed_codex_selection = wire.managed_codex_selection; stdout.run_metadata.managed_gemini_selection = wire.managed_gemini_selection; stdout.run_metadata.managed_cursor_selection = wire.managed_cursor_selection; + stdout.run_metadata.launch_lifecycle_metadata = wire.provider_launch_lifecycle; Ok(Self { command: wire.command, cwd: wire.cwd, @@ -5484,6 +5504,8 @@ struct ExternalAgentRunMetadata { managed_codex_selection: Option, managed_gemini_selection: Option, managed_cursor_selection: Option, + launch_lifecycle_handle: Option, + launch_lifecycle_metadata: Option, } /// Native successful end-turn, applied read-only filesystem and materialized held-output facts. @@ -6591,9 +6613,23 @@ struct ClaudeVerifiedAccountSession { #[cfg(target_os = "linux")] fn prepare_verified_grok_account_session( spec: &ExternalAgentCommand, + deadline: Instant, + lifecycle: &mut Option, ) -> Result { let frozen = spec.grok_run_account_binding.as_ref(); - let authority = crate::account_authority::grok::acquire_grok_launch_authority(frozen)?; + let operation_id = private_parent_nonce()?; + let run_id = spec + .agent_lifecycle + .as_ref() + .map(|identity| identity.run_id.as_str()) + .unwrap_or(&operation_id); + let authority = crate::account_authority::grok::acquire_guarded_grok_launch_authority( + frozen, + run_id, + &operation_id, + deadline, + lifecycle, + )?; if let Some(frozen) = frozen { let evidence = authority.selection_evidence(); if !frozen.matches_selection_evidence(&evidence) { @@ -6607,8 +6643,9 @@ fn prepare_verified_grok_account_session( ); } } - let credentials = - AdmittedGrokCredentials::from_managed_grok_home(authority.managed_grok_home())?; + let credentials = authority.construct_material(AdmittedGrokCredentials::from_managed_grok_home); + *lifecycle = authority.launch_lifecycle(); + let credentials = credentials?; Ok(GrokVerifiedAccountSession { authority, credentials, @@ -7789,7 +7826,11 @@ fn run_external_agent_runtime( let grok_session = if runtime == ExternalExecutionRuntime::Verified && spec.invocation == ExternalAgentInvocation::Grok { - match prepare_verified_grok_account_session(spec) { + match prepare_verified_grok_account_session( + spec, + started.checked_add(spec.timeout).unwrap_or(started), + &mut report.stdout.run_metadata.launch_lifecycle_handle, + ) { Ok(session) => { report.stdout.run_metadata.managed_grok_selection = Some(session.authority.selection_evidence()); @@ -8341,6 +8382,15 @@ fn run_external_agent_runtime( report.stdout.run_metadata.environment_preflight_results = preflight.results; report.stdout.run_metadata.environment_failures = preflight.failures; retain_environment_preflight_process_evidence(&mut report, &preflight.process_evidence); + #[cfg(target_os = "linux")] + if preflight.process_evidence.may_have_live_child() { + if let Some(mut custody) = grok_session + .as_ref() + .and_then(|session| session.authority.launch_custody()) + { + custody.exit_unknown(); + } + } if report.environment_blocked() { report.duration_ms = duration_millis(started.elapsed()); report.error = Some(environment_blocked_message(report.environment_failures())); @@ -8728,6 +8778,13 @@ fn run_external_agent_runtime( .with_stdin_limit(MAX_PROMPT_BYTES) .with_timeout(Some(timeout)) .with_stdout(StreamCapture::bounded(OUTPUT_TEE_LIMIT_BYTES)); + let process_spec = if target_spec.invocation == ExternalAgentInvocation::Grok { + // The runner carries this same admission deadline through containment + // setup and the paused start gate, including time spent before dispatch. + process_spec.with_absolute_deadline(started.checked_add(spec.timeout).unwrap_or(started)) + } else { + process_spec + }; let mut process_spec = match runtime { ExternalExecutionRuntime::Verified => { let Some(side_effect_profile) = side_effect_profile else { @@ -8985,6 +9042,12 @@ fn run_external_agent_runtime( let mut retained_review_metrics = None; let mut failed_claude_broker_witness: Option<(ClaudeWholeCallWitness, ClaudeBrokerBinding)> = None; + #[cfg(target_os = "linux")] + let mut grok_launch_custody = grok_session + .as_ref() + .and_then(|session| session.authority.launch_custody()); + #[cfg(not(target_os = "linux"))] + let mut grok_launch_custody: Option<&mut ExternalLaunchCustody> = None; let process_result = if app_server_required { let Some(prompt) = app_server_prompt else { report.duration_ms = duration_millis(started.elapsed()); @@ -9193,6 +9256,7 @@ fn run_external_agent_runtime( &target_spec, prompt, timeout, + grok_launch_custody.as_deref_mut(), ) { Ok(interactive) => { let mut parent_evidence = None; @@ -9474,18 +9538,25 @@ fn run_external_agent_runtime( unreachable!("managed Cursor was refused before process construction") } } else { - let batch = if grok_progress::guarded_worker(&target_spec) - && runtime == ExternalExecutionRuntime::Verified - { - grok_progress::run( - process_spec, - cancellation, - target_spec.worker_change_required, - ) - } else { - run_codex_batch_process(process_spec, cancellation, codex_auth.as_ref()) - .map(|(output, filesystem)| (output, filesystem, None)) - }; + let batch = executor::launch_lifecycle::execute( + grok_launch_custody.as_deref_mut(), + cancellation, + || { + if grok_progress::guarded_worker(&target_spec) + && runtime == ExternalExecutionRuntime::Verified + { + grok_progress::run( + process_spec, + cancellation, + target_spec.worker_change_required, + ) + } else { + run_codex_batch_process(process_spec, cancellation, codex_auth.as_ref()) + .map(|(output, filesystem)| (output, filesystem, None)) + } + }, + |(output, _, _)| output, + ); batch.map(|(output, filesystem, prior)| { // Bind the original supervisor command, not target_spec's private // staging destination. Retain spend before any fallible materialization. @@ -10755,6 +10826,7 @@ fn run_grok_acp_external_process( spec: &ExternalAgentCommand, prompt: String, operation_timeout: Duration, + custody: Option<&mut ExternalLaunchCustody>, ) -> Result, ProcessRunError> { if spec.workspace_access == WorkspaceAccess::ReadWrite { let context = LaunchContext { @@ -10811,31 +10883,38 @@ fn run_grok_acp_external_process( source: std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string()), })?; - let interactive = run_process_interactive(process_spec, cancellation, |session| { - let mut transport = GrokAcpContainedTransport::new(session); - let cancelled = || cancellation.is_cancelled(); - let evidence = if let Some(bridge) = steering_bridge.as_mut() { - crate::runtime_adapter::grok_acp::run_grok_acp_turn_with_steering( - &mut transport, - &turn, - limits, - cancelled, - bridge, - ) - } else { - crate::runtime_adapter::grok_acp::run_grok_acp_turn( - &mut transport, - &turn, - limits, - cancelled, - ) - } - .map_err(|error| error.to_string())?; - Ok(GrokAcpInteractiveOutcome { - evidence, - launch_schema: turn.output_schema.clone(), - }) - }); + let interactive = executor::launch_lifecycle::execute( + custody, + cancellation, + || { + run_process_interactive(process_spec, cancellation, |session| { + let mut transport = GrokAcpContainedTransport::new(session); + let cancelled = || cancellation.is_cancelled(); + let evidence = if let Some(bridge) = steering_bridge.as_mut() { + crate::runtime_adapter::grok_acp::run_grok_acp_turn_with_steering( + &mut transport, + &turn, + limits, + cancelled, + bridge, + ) + } else { + crate::runtime_adapter::grok_acp::run_grok_acp_turn( + &mut transport, + &turn, + limits, + cancelled, + ) + } + .map_err(|error| error.to_string())?; + Ok(GrokAcpInteractiveOutcome { + evidence, + launch_schema: turn.output_schema.clone(), + }) + }) + }, + |interactive| &interactive.process, + ); if let Some(bridge) = steering_bridge { bridge @@ -12037,6 +12116,14 @@ struct EnvironmentPreflightProcessEvidence { } impl EnvironmentPreflightProcessEvidence { + #[cfg(target_os = "linux")] + fn may_have_live_child(&self) -> bool { + self.started + && !self + .process_tree + .is_some_and(ProcessTreeEvidence::is_verified_empty) + } + fn record_output(&mut self, output: &ProcessOutput) { self.record( output.process_tree, @@ -12073,8 +12160,6 @@ impl EnvironmentPreflightProcessEvidence { evidence.process_tree.is_verified_empty() && evidence.side_effects.is_verified(), ), ProcessRunError::OpenTee { .. } - | ProcessRunError::TeeConflict { .. } - | ProcessRunError::SetupTimeout { .. } | ProcessRunError::ProcessOwnership { .. } | ProcessRunError::IoSetup { .. } => self.record( ProcessTreeEvidence::Unverified(ContainmentBackend::SystemdUserService), @@ -12094,6 +12179,8 @@ impl EnvironmentPreflightProcessEvidence { false, ), ProcessRunError::Cancelled { evidence: None, .. } + | ProcessRunError::SetupTimeout { .. } + | ProcessRunError::TeeConflict { .. } | ProcessRunError::Spawn { .. } | ProcessRunError::ContainmentUnavailable { .. } | ProcessRunError::EnvironmentFailure { @@ -12128,6 +12215,8 @@ fn process_run_error_definitely_before_process_start(error: &ProcessRunError) -> matches!( error, ProcessRunError::Cancelled { evidence: None, .. } + | ProcessRunError::SetupTimeout { .. } + | ProcessRunError::TeeConflict { .. } | ProcessRunError::Spawn { .. } | ProcessRunError::ContainmentUnavailable { .. } | ProcessRunError::EnvironmentFailure { diff --git a/src/external_agent/executor/launch_lifecycle.rs b/src/external_agent/executor/launch_lifecycle.rs new file mode 100644 index 00000000..2eef1447 --- /dev/null +++ b/src/external_agent/executor/launch_lifecycle.rs @@ -0,0 +1,109 @@ +//! Live process evidence is the only external-runner finalization authority. + +use coding_agent_manager_lib::providers::launch_lifecycle::{ + ExternalLaunchCustody, LaunchFinalization, +}; + +use crate::process_runner::{ProcessCancellation, ProcessOutput, ProcessRunError}; + +/// Invoke the real runner exactly once after admission; settle from its live +/// result before output materialization or protocol validation can fail. +pub(crate) fn execute( + mut custody: Option<&mut ExternalLaunchCustody>, + cancellation: &ProcessCancellation, + run: impl FnOnce() -> Result, + output: impl FnOnce(&T) -> &ProcessOutput, +) -> Result { + if let Some(custody) = custody.as_deref_mut() { + custody + .begin_spawn() + .map_err(|_| ProcessRunError::EnvironmentFailure { + label: "external launch".into(), + command: "declared provider".into(), + failure: Box::new(super::super::environment_failure( + super::super::EnvironmentFailureCategory::MissingCredential, + None, + "declared launch admission was refused".into(), + )), + target_process_started: false, + })?; + } + let result = run(); + match &result { + Ok(value) => observe_output(custody, output(value), cancellation.is_cancelled()), + Err(error) => observe_error(custody, error), + } + result +} + +/// Settle custody before report/output processing can fail or discard evidence. +pub(crate) fn observe_output( + custody: Option<&mut ExternalLaunchCustody>, + output: &ProcessOutput, + cancelled: bool, +) { + let Some(custody) = custody else { return }; + if !output.process_tree.is_verified_empty() { + custody.exit_unknown(); + return; + } + let reason = if output.timed_out || cancelled { + LaunchFinalization::Cancelled + } else if let Some(status) = output.status { + if status.success() { + LaunchFinalization::Exited + } else { + LaunchFinalization::AbnormalExit + } + } else { + custody.exit_unknown(); + return; + }; + custody.finalize(reason); +} + +pub(crate) fn observe_error(custody: Option<&mut ExternalLaunchCustody>, error: &ProcessRunError) { + let Some(custody) = custody else { return }; + if let ProcessRunError::Wait { evidence, .. } = error { + if evidence.process_tree.is_verified_empty() { + custody.finalize(LaunchFinalization::AbnormalExit); + } else { + custody.exit_unknown(); + } + return; + } + if let Some(evidence) = error.registration_rollback_evidence() { + if evidence.process_tree.is_verified_empty() { + custody.finalize(LaunchFinalization::Refused); + } else { + custody.exit_unknown(); + } + return; + } + if let Some(evidence) = error.cancellation_evidence() { + if evidence.process_tree.is_verified_empty() { + custody.finalize(LaunchFinalization::Cancelled); + } else { + custody.exit_unknown(); + } + return; + } + match error { + ProcessRunError::Spawn { .. } => custody.finalize(LaunchFinalization::SpawnFailed), + ProcessRunError::SetupTimeout { .. } + | ProcessRunError::TeeConflict { .. } + | ProcessRunError::StdinTooLarge { .. } + | ProcessRunError::ContainmentUnavailable { .. } + | ProcessRunError::EnvironmentFailure { + target_process_started: false, + .. + } + | ProcessRunError::Cancelled { evidence: None, .. } => { + custody.finalize(LaunchFinalization::Refused) + } + _ => custody.exit_unknown(), + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests; diff --git a/src/external_agent/executor/launch_lifecycle/tests.rs b/src/external_agent/executor/launch_lifecycle/tests.rs new file mode 100644 index 00000000..ebc6220d --- /dev/null +++ b/src/external_agent/executor/launch_lifecycle/tests.rs @@ -0,0 +1,531 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; +use std::thread; +use std::time::{Duration, Instant}; + +use coding_agent_manager_lib::account_authority::StoredAccountRegistry; +use coding_agent_manager_lib::error::Error as CamError; +use coding_agent_manager_lib::providers::launch_lifecycle::{ + CredentialEnvironmentInput, LaunchLifecycleHandle, LaunchPhase, +}; + +use super::*; +use crate::account_authority::authority_socket_config::CamAuthoritySocketTestGuard; +use crate::account_authority::grok::{ + acquire_guarded_grok_launch_authority, activate_cam_grok_test_harness, + build_cam_grok_test_harness, CamGrokTestGuard, GrokLaunchAuthority, +}; +use crate::external_agent::{ + insert_admitted_grok_home_environment, run_grok_acp_external_process, AdmittedGrokCredentials, + ExternalAgentCommand, +}; +use crate::process_runner::{ + run_process_cancellable, ContainmentBackend, ContainmentPolicy, EnvironmentMode, + ProcessFailureEvidence, ProcessSpec, ProcessTreeEvidence, SideEffectConfinementEvidence, + SideEffectConfinementProfileKind, StdinMode, +}; + +fn fixture() -> (CamGrokTestGuard, GrokLaunchAuthority, StoredAccountRegistry) { + let (harness, _, _) = build_cam_grok_test_harness("account-a").unwrap(); + let registry = StoredAccountRegistry::new(harness.registry.metadata_path()); + let guard = activate_cam_grok_test_harness(harness); + let authority = acquire_guarded_grok_launch_authority( + None, + "lifecycle-run", + "lifecycle-operation", + Instant::now() + Duration::from_secs(120), + &mut None, + ) + .unwrap(); + (guard, authority, registry) +} + +fn construct( + authority: &mut GrokLaunchAuthority, +) -> (AdmittedGrokCredentials, LaunchLifecycleHandle) { + let credentials = authority + .construct_material(AdmittedGrokCredentials::from_managed_grok_home) + .unwrap(); + (credentials, authority.launch_lifecycle().unwrap()) +} + +fn assert_busy(registry: &StoredAccountRegistry) { + assert!(matches!( + registry.begin_delete("grok-cli", "account-a"), + Err(CamError::AccountAuthorityBusy { .. }) + )); + assert!(matches!( + registry.select_complete("grok-cli", "account-b"), + Err(CamError::AccountAuthorityBusy { .. }) + )); +} + +fn assert_released(registry: &StoredAccountRegistry) { + registry.select_complete("grok-cli", "account-b").unwrap(); + registry.begin_delete("grok-cli", "account-a").unwrap(); +} + +fn child_spec(directory: &Path, environment: BTreeMap) -> ProcessSpec { + ProcessSpec::direct( + "declared lifecycle fixture", + "/bin/sh", + [ + "-c", + "test -z \"${XAI_API_KEY+x}\" && test -z \"${GROK_AUTH_PATH+x}\" || exit 90; test -n \"$GROK_HOME\" || exit 91; touch ready; while [ ! -e release ]; do sleep 0.01; done; read code < release; exit \"$code\"", + ], + directory, + 4096, + ) + .with_environment(EnvironmentMode::ClearAndSet(environment)) + .with_stdin(StdinMode::Null) + .with_timeout(Some(Duration::from_secs(45))) +} + +// This fixture has no child-spawning code. Its runner supplies synthetic +// direct-child empty-tree evidence only after waiting for that exact child. +// It exercises lifecycle mapping without claiming strict backend qualification. +#[test] +fn lifecycle_child_fixture() { + let Some(directory) = std::env::var_os("MACO_LIFECYCLE_CHILD_DIRECTORY") else { + return; + }; + assert!(std::env::var_os("XAI_API_KEY").is_none()); + assert!(std::env::var_os("GROK_AUTH_PATH").is_none()); + assert!(std::env::var_os("GROK_HOME").is_some()); + let directory = std::path::PathBuf::from(directory); + fs::write(directory.join("ready"), b"ready").unwrap(); + let deadline = Instant::now() + Duration::from_secs(45); + while !directory.join("release").exists() { + assert!(Instant::now() < deadline, "fixture release timed out"); + thread::sleep(Duration::from_millis(10)); + } + if fs::read_to_string(directory.join("release")) + .unwrap() + .trim() + != "0" + { + panic!("synthetic nonzero exit"); + } +} + +fn run_fixture_child(spec: ProcessSpec, cancellation: &ProcessCancellation) -> ProcessOutput { + let EnvironmentMode::ClearAndSet(mut environment) = spec.environment else { + panic!("fixture requires an explicit environment"); + }; + environment.insert( + "MACO_LIFECYCLE_CHILD_DIRECTORY".into(), + spec.current_dir.to_string_lossy().into_owned(), + ); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "external_agent::executor::launch_lifecycle::tests::lifecycle_child_fixture", + ]) + .current_dir(&spec.current_dir) + .env_clear() + .envs(environment) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if cancellation.is_cancelled() || started.elapsed() > Duration::from_secs(45) { + child.kill().unwrap(); + break child.wait().unwrap(); + } + thread::sleep(Duration::from_millis(10)); + }; + ProcessOutput { + status: Some(status), + duration: started.elapsed(), + timed_out: false, + process_tree: ProcessTreeEvidence::VerifiedEmpty(ContainmentBackend::DirectChild), + side_effects: SideEffectConfinementEvidence::TrustedBestEffort( + SideEffectConfinementProfileKind::TrustedCompatibility, + ), + stdout: Default::default(), + stderr: Default::default(), + process_error: None, + stdin_error: None, + } +} + +#[test] +fn production_launch_lifecycle_tracks_child_exit() { + exercise_launch_paths(false); +} + +#[test] +#[ignore = "requires a qualified delegated Linux user manager and strict containment backend"] +fn production_launch_lifecycle_strict_runner_tracks_child_exit() { + exercise_launch_paths(true); +} + +fn exercise_launch_paths(strict: bool) { + let _socket = CamAuthoritySocketTestGuard::install(None); + for terminal in ["normal", "nonzero", "cancel"] { + let (_guard, mut authority, registry) = fixture(); + let (credentials, handle) = construct(&mut authority); + let mut environment = BTreeMap::from([ + ( + "PATH".into(), + "/usr/bin:/bin:/run/current-system/sw/bin".into(), + ), + ("XAI_API_KEY".into(), "FAKE-inherited-input".into()), + ("GROK_AUTH_PATH".into(), "FAKE-inherited-path".into()), + ]); + insert_admitted_grok_home_environment(&mut environment, &credentials).unwrap(); + authority.apply_launch_environment(&mut environment); + assert!(!environment.contains_key("XAI_API_KEY")); + let directory = tempfile::tempdir().unwrap(); + let spec = child_spec(directory.path(), environment); + let cancellation = ProcessCancellation::new(); + let child_cancellation = cancellation.clone(); + let runner = thread::spawn(move || { + let result = execute( + authority.launch_custody().as_deref_mut(), + &child_cancellation, + || { + if strict { + run_process_cancellable(spec, &child_cancellation) + } else { + Ok(run_fixture_child(spec, &child_cancellation)) + } + }, + |output| output, + ); + (authority, credentials, result) + }); + let deadline = Instant::now() + Duration::from_secs(60); + while !directory.path().join("ready").exists() && !runner.is_finished() { + assert!( + Instant::now() < deadline, + "fixture did not reach its child barrier" + ); + thread::sleep(Duration::from_millis(10)); + } + if !directory.path().join("ready").exists() { + let (_, _, result) = runner.join().unwrap(); + panic!("child failed before barrier: {result:?}"); + } + assert_eq!(handle.snapshot().phase, LaunchPhase::Running); + assert_busy(®istry); + assert!(handle.revoke_before_spawn().is_err()); + if terminal == "cancel" { + cancellation.cancel(); + } else { + fs::write( + directory.path().join("release.pending"), + if terminal == "normal" { "0\n" } else { "7\n" }, + ) + .unwrap(); + fs::rename( + directory.path().join("release.pending"), + directory.path().join("release"), + ) + .unwrap(); + } + let (authority, credentials, result) = runner.join().unwrap(); + let output = result.unwrap(); + assert!(output.process_tree.is_verified_empty()); + let expected = match terminal { + "normal" => LaunchFinalization::Exited, + "nonzero" => LaunchFinalization::AbnormalExit, + _ => LaunchFinalization::Cancelled, + }; + assert_eq!(handle.snapshot().phase, LaunchPhase::Finalized(expected)); + // Reobserving a terminal result cannot change the first finalization. + observe_output(authority.launch_custody().as_deref_mut(), &output, true); + assert_eq!(handle.snapshot().phase, LaunchPhase::Finalized(expected)); + assert!(credentials.grok_home_environment().is_ok()); + assert_busy(®istry); + let metadata = crate::secret_lifecycle::ProviderLaunchMetadata::from(handle.snapshot()); + let serialized = serde_json::to_string(&metadata).unwrap(); + for private in [ + "FAKE-managed-refresh", + "FAKE-inherited-input", + "FAKE-inherited-path", + "GROK_HOME", + "auth.json", + ] { + assert!(!serialized.contains(private)); + } + assert_eq!(metadata.run_id, "lifecycle-run"); + assert_eq!( + metadata.binding.account_incarnation, + authority.binding().account_incarnation + ); + assert!(metadata + .environment + .contains(&CredentialEnvironmentInput::Removed("XAI_API_KEY".into()))); + drop(credentials); + assert_busy(®istry); + drop(authority); + assert_released(®istry); + } +} + +fn pre_release_error(kind: &str) -> ProcessRunError { + match kind { + "setup-timeout" => ProcessRunError::SetupTimeout { + label: "lifecycle fixture".into(), + command: "fixture".into(), + phase: "containment setup before release", + source: std::io::Error::new(std::io::ErrorKind::TimedOut, "synthetic setup timeout"), + }, + "tee-conflict" => ProcessRunError::TeeConflict { + label: "lifecycle fixture".into(), + stdout: "fixture-tee".into(), + stderr: "fixture-tee".into(), + }, + _ => panic!("unknown fixture refusal"), + } +} + +#[test] +fn production_launch_lifecycle_pre_release_refusals_release_custody() { + let _socket = CamAuthoritySocketTestGuard::install(None); + for kind in ["setup-timeout", "tee-conflict"] { + let (_guard, mut authority, registry) = fixture(); + let (credentials, handle) = construct(&mut authority); + { + let mut custody = authority.launch_custody().unwrap(); + custody.begin_spawn().unwrap(); + assert_eq!(handle.snapshot().phase, LaunchPhase::Running); + observe_error(Some(&mut custody), &pre_release_error(kind)); + } + assert_eq!( + handle.snapshot().phase, + LaunchPhase::Finalized(LaunchFinalization::Refused) + ); + assert_busy(®istry); + drop(credentials); + drop(authority); + assert_released(®istry); + } +} + +#[test] +fn production_launch_lifecycle_preflight_refusals_preserve_live_child_evidence() { + use crate::external_agent::EnvironmentPreflightProcessEvidence; + + let _socket = CamAuthoritySocketTestGuard::install(None); + for kind in ["setup-timeout", "tee-conflict"] { + for earlier_live_child in [false, true] { + let (_guard, mut authority, registry) = fixture(); + let (credentials, handle) = construct(&mut authority); + let mut evidence = EnvironmentPreflightProcessEvidence::default(); + if earlier_live_child { + evidence.record( + ProcessTreeEvidence::Unverified(ContainmentBackend::SystemdUserService), + SideEffectConfinementEvidence::Unverified( + SideEffectConfinementProfileKind::ExternalGrok, + ), + false, + ); + } + evidence.record_error(&pre_release_error(kind)); + assert_eq!(evidence.started, earlier_live_child); + assert_eq!(evidence.may_have_live_child(), earlier_live_child); + if evidence.may_have_live_child() { + authority.launch_custody().unwrap().exit_unknown(); + } + drop(credentials); + drop(authority); + if earlier_live_child { + assert_eq!(handle.snapshot().phase, LaunchPhase::ExitUnknown); + assert_busy(®istry); + } else { + assert_eq!( + handle.snapshot().phase, + LaunchPhase::Finalized(LaunchFinalization::Refused) + ); + assert_released(®istry); + } + } + } +} + +#[test] +fn production_launch_lifecycle_refuses_expired_revoked_and_failed_construction() { + let _socket = CamAuthoritySocketTestGuard::install(None); + for refusal in [ + "expired", + "construction", + "revoked", + "spawn", + "acp-contract", + "drop", + ] { + let (_guard, mut authority, registry) = fixture(); + if matches!(refusal, "expired" | "construction") { + let mut constructed = false; + let mut handle = authority.launch_lifecycle(); + let result = if refusal == "expired" { + acquire_guarded_grok_launch_authority( + None, + "expired-run", + "expired-operation", + Instant::now(), + &mut handle, + ) + .map(|_| ()) + } else { + authority.construct_material(|_| { + constructed = true; + assert_busy(®istry); + anyhow::bail!("synthetic construction refusal") + }) + }; + assert!(result.is_err()); + assert_eq!(constructed, refusal == "construction"); + drop(authority); + assert_eq!( + handle.unwrap().snapshot().phase, + LaunchPhase::Finalized(LaunchFinalization::Refused) + ); + } else { + let (_credentials, handle) = construct(&mut authority); + if refusal == "revoked" { + handle.revoke_before_spawn().unwrap(); + let result: Result = execute( + authority.launch_custody().as_deref_mut(), + &ProcessCancellation::new(), + || panic!("revoked admission must not invoke the runner"), + |output| output, + ); + assert!(result.is_err()); + } else if refusal == "spawn" { + let error = std::process::Command::new("/missing-lifecycle-fixture/program") + .spawn() + .unwrap_err(); + let result: Result = execute( + authority.launch_custody().as_deref_mut(), + &ProcessCancellation::new(), + || { + Err(ProcessRunError::Spawn { + label: "fixture".into(), + command: "fixture".into(), + current_dir: Path::new("/").into(), + source: error, + }) + }, + |output| output, + ); + assert!(result.is_err()); + } else if refusal == "acp-contract" { + let directory = tempfile::tempdir().unwrap(); + let command = ExternalAgentCommand::codex( + "unused-provider", + directory.path(), + directory.path().join("prompt"), + directory.path().join("events"), + directory.path().join("message"), + Duration::from_secs(60), + ); + let result = run_grok_acp_external_process( + child_spec(directory.path(), BTreeMap::new()), + &ProcessCancellation::new(), + &command, + "fixture".into(), + Duration::from_secs(60), + authority.launch_custody().as_deref_mut(), + ); + assert!(matches!(result, Err(ProcessRunError::IoSetup { .. }))); + assert_eq!(handle.snapshot().phase, LaunchPhase::Constructing); + } + drop(authority); + let expected = if refusal == "spawn" { + LaunchFinalization::SpawnFailed + } else { + LaunchFinalization::Refused + }; + assert_eq!(handle.snapshot().phase, LaunchPhase::Finalized(expected)); + } + assert_released(®istry); + } +} + +#[test] +fn production_launch_lifecycle_retains_unknown_exit_custody() { + let _socket = CamAuthoritySocketTestGuard::install(None); + for failure in ["wait", "cancel", "preflight", "abandoned"] { + let (_guard, mut authority, registry) = fixture(); + let (_credentials, handle) = construct(&mut authority); + let mut custody = authority.launch_custody().unwrap(); + if failure != "preflight" { + custody.begin_spawn().unwrap(); + } + if matches!(failure, "wait" | "cancel") { + let evidence = Box::new(ProcessFailureEvidence { + stdout: Default::default(), + stderr: Default::default(), + process_tree: ProcessTreeEvidence::Unverified( + ContainmentBackend::SystemdUserService, + ), + side_effects: SideEffectConfinementEvidence::Unverified( + SideEffectConfinementProfileKind::ExternalGrok, + ), + process_error: None, + stdin_error: None, + }); + let error = if failure == "wait" { + ProcessRunError::Wait { + label: "fixture".into(), + command: "fixture".into(), + evidence, + source: std::io::Error::other("synthetic wait failure"), + } + } else { + ProcessRunError::Cancelled { + label: "fixture".into(), + command: "fixture".into(), + phase: "synthetic cleanup", + evidence: Some(evidence), + } + }; + observe_error(Some(&mut custody), &error); + } else if failure == "preflight" { + custody.exit_unknown(); + } + drop(custody); + drop(authority); + assert_eq!(handle.snapshot().phase, LaunchPhase::ExitUnknown); + assert_busy(®istry); + } +} + +#[test] +fn production_launch_lifecycle_best_effort_exit_does_not_confirm_cleanup() { + let _socket = CamAuthoritySocketTestGuard::install(None); + let (_guard, mut authority, registry) = fixture(); + let (_credentials, handle) = construct(&mut authority); + let directory = tempfile::tempdir().unwrap(); + let spec = ProcessSpec::direct( + "unverified fixture", + "/bin/sh", + ["-c", "exit 0"], + directory.path(), + 1024, + ) + .with_containment(ContainmentPolicy::TrustedBestEffort) + .with_environment(EnvironmentMode::ClearAndSet(BTreeMap::new())) + .with_stdin(StdinMode::Null); + let output = execute( + authority.launch_custody().as_deref_mut(), + &ProcessCancellation::new(), + || run_process_cancellable(spec, &ProcessCancellation::new()), + |output| output, + ) + .unwrap(); + assert!(output.status.unwrap().success()); + assert_eq!(handle.snapshot().phase, LaunchPhase::ExitUnknown); + drop(authority); + assert_busy(®istry); +} diff --git a/src/external_agent/executor/mod.rs b/src/external_agent/executor/mod.rs index b243d84d..2254c240 100644 --- a/src/external_agent/executor/mod.rs +++ b/src/external_agent/executor/mod.rs @@ -18,6 +18,7 @@ //! outside this executor-owned foundation. mod checksum; +pub(crate) mod launch_lifecycle; mod local; mod selection; mod ssh; diff --git a/src/external_agent/tests.rs b/src/external_agent/tests.rs index 023e78dc..2223c381 100644 --- a/src/external_agent/tests.rs +++ b/src/external_agent/tests.rs @@ -11068,17 +11068,6 @@ fn environment_preflight_process_errors_retain_fail_closed_containment_evidence( path: PathBuf::from("injected-tee"), source: std::io::Error::other("injected tee failure"), }, - ProcessRunError::TeeConflict { - label: "codex version preflight".to_string(), - stdout: PathBuf::from("injected-stdout"), - stderr: PathBuf::from("injected-stderr"), - }, - ProcessRunError::SetupTimeout { - label: "codex version preflight".to_string(), - command: "codex --version".to_string(), - phase: "injected post-spawn setup", - source: std::io::Error::other("injected setup timeout"), - }, ProcessRunError::ProcessOwnership { label: "codex version preflight".to_string(), command: "codex --version".to_string(), @@ -11130,6 +11119,17 @@ fn environment_preflight_process_errors_retain_fail_closed_containment_evidence( } let pre_spawn_errors = vec![ + ProcessRunError::TeeConflict { + label: "codex version preflight".to_string(), + stdout: PathBuf::from("injected-stdout"), + stderr: PathBuf::from("injected-stderr"), + }, + ProcessRunError::SetupTimeout { + label: "codex version preflight".to_string(), + command: "codex --version".to_string(), + phase: "injected pre-release setup", + source: std::io::Error::other("injected setup timeout"), + }, ProcessRunError::Spawn { label: "codex version preflight".to_string(), command: "codex --version".to_string(), @@ -13891,6 +13891,53 @@ fn verified_grok_cam_run_releases_use_lease_on_failure() -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +#[test] +fn verified_grok_cam_lifecycle_refusal_serializes_references_only() -> Result<()> { + use coding_agent_manager_lib::account_authority::StoredAccountRegistry; + use coding_agent_manager_lib::providers::launch_lifecycle::{LaunchFinalization, LaunchPhase}; + + let _no_socket = CamSocketEnvGuard::unset(); + let temp = tempfile::tempdir()?; + let (harness, home, expected_binding) = build_cam_grok_test_harness("account-a")?; + let registry = StoredAccountRegistry::new(harness.registry.metadata_path()); + let command = admitted_verified_grok_cam_command(temp.path())?; + let _fixture_guard = crate::account_authority::activate_cam_grok_test_harness(harness); + GROK_PREFLIGHT_HOOK.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move || { + fs::rename(home.join("auth.json"), home.join("held-auth.json")).unwrap(); + })); + }); + let report = run_external_agent(&command); + assert!(GROK_PREFLIGHT_HOOK.with(|hook| hook.borrow().is_none())); + assert!(!report.stdout.target_launch_attempted); + assert!(report.environment_blocked()); + let lifecycle = report + .provider_launch_lifecycle() + .context("declared lifecycle missing")?; + assert_eq!(lifecycle.run_id, "verified-grok-cam"); + assert_eq!( + lifecycle.binding.account_incarnation, + expected_binding.account_incarnation + ); + assert_eq!( + lifecycle.phase, + LaunchPhase::Finalized(LaunchFinalization::Refused) + ); + let serialized = serde_json::to_string(&report)?; + assert!(!serialized.contains("FAKE-managed-refresh")); + let restored: ExternalAgentRun = serde_json::from_str(&serialized)?; + assert_eq!(restored.provider_launch_lifecycle(), Some(lifecycle)); + assert!(restored + .stdout + .run_metadata + .launch_lifecycle_handle + .is_none()); + registry.select_complete("grok-cli", "account-b")?; + registry.begin_delete("grok-cli", "account-a")?; + Ok(()) +} + #[cfg(target_os = "linux")] fn serve_cam_registry_on_socket( registry: coding_agent_manager_lib::account_authority::StoredAccountRegistry, diff --git a/src/secret_lifecycle.rs b/src/secret_lifecycle.rs index 90ff24d6..978be835 100644 --- a/src/secret_lifecycle.rs +++ b/src/secret_lifecycle.rs @@ -5,8 +5,11 @@ //! //! This module is the lifecycle governance surface. It does **not** replace //! bounded credential redaction in `external_agent` (presence detection plus -//! log/report scrubbing). Callers that still copy host environment values into -//! child processes, plans, or journals remain a cross-lane handoff. +//! log/report scrubbing). CAM selected launches and root Grok dispatch use the +//! equivalent closed operation: an exact incarnation/use lease and run-bound admission. +//! The root Grok executor opens its vendor-home capability during guarded +//! construction and settles custody only from live process-tree evidence. +//! [`ProviderLaunchMetadata`] is reference-only descriptive evidence. //! //! The contract is: //! @@ -46,11 +49,13 @@ mod audit; mod injection; mod material; +mod provider_dispatch; mod types; mod vault; pub use audit::{SecretAuditAction, SecretAuditEvent, SecretAuditOutcome, SecretAuditTrail}; pub use injection::{SecretEnvironment, SecretLease}; +pub use provider_dispatch::ProviderLaunchMetadata; pub use types::{ DelegationPolicy, PersistencePolicy, SecretDeclaration, SecretLifecycleError, SecretPlanBinding, SecretRef, SecretScope, SecretState, SecretStatusView, diff --git a/src/secret_lifecycle/provider_dispatch.rs b/src/secret_lifecycle/provider_dispatch.rs new file mode 100644 index 00000000..60410b29 --- /dev/null +++ b/src/secret_lifecycle/provider_dispatch.rs @@ -0,0 +1,33 @@ +//! Reference-only evidence from the closed stored-account launch operation. + +use coding_agent_manager_lib::account_authority::SelectedAccountBinding; +use coding_agent_manager_lib::providers::launch_lifecycle::{ + CredentialEnvironmentInput, LaunchLifecycleView, LaunchPhase, +}; +use serde::{Deserialize, Serialize}; + +/// Descriptive evidence only. Restoring it cannot recreate a lease, credential +/// capability, pending admission, or authority to finalize a live child. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderLaunchMetadata { + pub run_id: String, + pub operation_id: String, + pub binding: SelectedAccountBinding, + pub environment: Vec, + pub phase: LaunchPhase, + pub revoked: bool, +} + +impl From for ProviderLaunchMetadata { + fn from(view: LaunchLifecycleView) -> Self { + Self { + run_id: view.run_id, + operation_id: view.operation_id, + binding: view.binding, + environment: view.environment, + phase: view.phase, + revoked: view.revoked, + } + } +} From 3f88356e7f3c9ec552c84b0a40ff0b8ab340c8ef Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:55:18 +0900 Subject: [PATCH 2/3] Gate selected-account lifecycle binding to Linux --- src/external_agent.rs | 107 +++++++++++++++++------------ src/external_agent/executor/mod.rs | 1 + src/secret_lifecycle.rs | 4 +- 3 files changed, 66 insertions(+), 46 deletions(-) diff --git a/src/external_agent.rs b/src/external_agent.rs index e8639ac7..cd41b31e 100644 --- a/src/external_agent.rs +++ b/src/external_agent.rs @@ -65,6 +65,7 @@ use crate::secure_output::{ReservedOutputFile, SecureOutputRoot}; use crate::supervise::AgentRole; use crate::worktree::normalize_agent_id; use anyhow::{bail, Context, Result}; +#[cfg(target_os = "linux")] use coding_agent_manager_lib::providers::launch_lifecycle::{ ExternalLaunchCustody, LaunchLifecycleHandle, }; @@ -4727,6 +4728,7 @@ impl std::fmt::Debug for RedactedByteCount { } impl ExternalAgentRun { + #[cfg(target_os = "linux")] pub fn provider_launch_lifecycle( &self, ) -> Option { @@ -5301,6 +5303,7 @@ struct ExternalAgentRunWireRef<'a> { managed_gemini_selection: &'a Option, #[serde(default, skip_serializing_if = "Option::is_none")] managed_cursor_selection: &'a Option, + #[cfg(target_os = "linux")] #[serde(skip_serializing_if = "Option::is_none")] provider_launch_lifecycle: Option, } @@ -5358,6 +5361,7 @@ struct ExternalAgentRunWireOwned { managed_gemini_selection: Option, #[serde(default)] managed_cursor_selection: Option, + #[cfg(target_os = "linux")] #[serde(default)] provider_launch_lifecycle: Option, } @@ -5407,6 +5411,7 @@ impl Serialize for ExternalAgentRun { managed_codex_selection: &self.stdout.run_metadata.managed_codex_selection, managed_gemini_selection: &self.stdout.run_metadata.managed_gemini_selection, managed_cursor_selection: &self.stdout.run_metadata.managed_cursor_selection, + #[cfg(target_os = "linux")] provider_launch_lifecycle: self.provider_launch_lifecycle(), } .serialize(serializer) @@ -5437,7 +5442,10 @@ impl<'de> Deserialize<'de> for ExternalAgentRun { stdout.run_metadata.managed_codex_selection = wire.managed_codex_selection; stdout.run_metadata.managed_gemini_selection = wire.managed_gemini_selection; stdout.run_metadata.managed_cursor_selection = wire.managed_cursor_selection; - stdout.run_metadata.launch_lifecycle_metadata = wire.provider_launch_lifecycle; + #[cfg(target_os = "linux")] + { + stdout.run_metadata.launch_lifecycle_metadata = wire.provider_launch_lifecycle; + } Ok(Self { command: wire.command, cwd: wire.cwd, @@ -5504,7 +5512,9 @@ struct ExternalAgentRunMetadata { managed_codex_selection: Option, managed_gemini_selection: Option, managed_cursor_selection: Option, + #[cfg(target_os = "linux")] launch_lifecycle_handle: Option, + #[cfg(target_os = "linux")] launch_lifecycle_metadata: Option, } @@ -9046,8 +9056,6 @@ fn run_external_agent_runtime( let mut grok_launch_custody = grok_session .as_ref() .and_then(|session| session.authority.launch_custody()); - #[cfg(not(target_os = "linux"))] - let mut grok_launch_custody: Option<&mut ExternalLaunchCustody> = None; let process_result = if app_server_required { let Some(prompt) = app_server_prompt else { report.duration_ms = duration_millis(started.elapsed()); @@ -9256,6 +9264,7 @@ fn run_external_agent_runtime( &target_spec, prompt, timeout, + #[cfg(target_os = "linux")] grok_launch_custody.as_deref_mut(), ) { Ok(interactive) => { @@ -9538,25 +9547,29 @@ fn run_external_agent_runtime( unreachable!("managed Cursor was refused before process construction") } } else { + let run_batch = || { + if grok_progress::guarded_worker(&target_spec) + && runtime == ExternalExecutionRuntime::Verified + { + grok_progress::run( + process_spec, + cancellation, + target_spec.worker_change_required, + ) + } else { + run_codex_batch_process(process_spec, cancellation, codex_auth.as_ref()) + .map(|(output, filesystem)| (output, filesystem, None)) + } + }; + #[cfg(target_os = "linux")] let batch = executor::launch_lifecycle::execute( grok_launch_custody.as_deref_mut(), cancellation, - || { - if grok_progress::guarded_worker(&target_spec) - && runtime == ExternalExecutionRuntime::Verified - { - grok_progress::run( - process_spec, - cancellation, - target_spec.worker_change_required, - ) - } else { - run_codex_batch_process(process_spec, cancellation, codex_auth.as_ref()) - .map(|(output, filesystem)| (output, filesystem, None)) - } - }, + run_batch, |(output, _, _)| output, ); + #[cfg(not(target_os = "linux"))] + let batch = run_batch(); batch.map(|(output, filesystem, prior)| { // Bind the original supervisor command, not target_spec's private // staging destination. Retain spend before any fallible materialization. @@ -10826,7 +10839,7 @@ fn run_grok_acp_external_process( spec: &ExternalAgentCommand, prompt: String, operation_timeout: Duration, - custody: Option<&mut ExternalLaunchCustody>, + #[cfg(target_os = "linux")] custody: Option<&mut ExternalLaunchCustody>, ) -> Result, ProcessRunError> { if spec.workspace_access == WorkspaceAccess::ReadWrite { let context = LaunchContext { @@ -10883,38 +10896,42 @@ fn run_grok_acp_external_process( source: std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string()), })?; + let run_interactive = || { + run_process_interactive(process_spec, cancellation, |session| { + let mut transport = GrokAcpContainedTransport::new(session); + let cancelled = || cancellation.is_cancelled(); + let evidence = if let Some(bridge) = steering_bridge.as_mut() { + crate::runtime_adapter::grok_acp::run_grok_acp_turn_with_steering( + &mut transport, + &turn, + limits, + cancelled, + bridge, + ) + } else { + crate::runtime_adapter::grok_acp::run_grok_acp_turn( + &mut transport, + &turn, + limits, + cancelled, + ) + } + .map_err(|error| error.to_string())?; + Ok(GrokAcpInteractiveOutcome { + evidence, + launch_schema: turn.output_schema.clone(), + }) + }) + }; + #[cfg(target_os = "linux")] let interactive = executor::launch_lifecycle::execute( custody, cancellation, - || { - run_process_interactive(process_spec, cancellation, |session| { - let mut transport = GrokAcpContainedTransport::new(session); - let cancelled = || cancellation.is_cancelled(); - let evidence = if let Some(bridge) = steering_bridge.as_mut() { - crate::runtime_adapter::grok_acp::run_grok_acp_turn_with_steering( - &mut transport, - &turn, - limits, - cancelled, - bridge, - ) - } else { - crate::runtime_adapter::grok_acp::run_grok_acp_turn( - &mut transport, - &turn, - limits, - cancelled, - ) - } - .map_err(|error| error.to_string())?; - Ok(GrokAcpInteractiveOutcome { - evidence, - launch_schema: turn.output_schema.clone(), - }) - }) - }, + run_interactive, |interactive| &interactive.process, ); + #[cfg(not(target_os = "linux"))] + let interactive = run_interactive(); if let Some(bridge) = steering_bridge { bridge diff --git a/src/external_agent/executor/mod.rs b/src/external_agent/executor/mod.rs index 5ccbc1e9..909b9f50 100644 --- a/src/external_agent/executor/mod.rs +++ b/src/external_agent/executor/mod.rs @@ -24,6 +24,7 @@ //! this executor-owned foundation. mod checksum; +#[cfg(target_os = "linux")] pub(crate) mod launch_lifecycle; mod local; mod selection; diff --git a/src/secret_lifecycle.rs b/src/secret_lifecycle.rs index 978be835..a343055e 100644 --- a/src/secret_lifecycle.rs +++ b/src/secret_lifecycle.rs @@ -9,7 +9,7 @@ //! equivalent closed operation: an exact incarnation/use lease and run-bound admission. //! The root Grok executor opens its vendor-home capability during guarded //! construction and settles custody only from live process-tree evidence. -//! [`ProviderLaunchMetadata`] is reference-only descriptive evidence. +//! On Linux, `ProviderLaunchMetadata` is reference-only descriptive evidence. //! //! The contract is: //! @@ -49,12 +49,14 @@ mod audit; mod injection; mod material; +#[cfg(target_os = "linux")] mod provider_dispatch; mod types; mod vault; pub use audit::{SecretAuditAction, SecretAuditEvent, SecretAuditOutcome, SecretAuditTrail}; pub use injection::{SecretEnvironment, SecretLease}; +#[cfg(target_os = "linux")] pub use provider_dispatch::ProviderLaunchMetadata; pub use types::{ DelegationPolicy, PersistencePolicy, SecretDeclaration, SecretLifecycleError, From c58a2c871fa833983d99bc36a3a5bf57a1ce3b1f Mon Sep 17 00:00:00 2001 From: Meta-Develop <116134763+Meta-Develop@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:30:45 +0900 Subject: [PATCH 3/3] Keep setup-timeout containment evidence while refusing the launch --- src/external_agent.rs | 29 +++++++++++---- .../executor/launch_lifecycle/tests.rs | 36 ++++++++++++++----- src/external_agent/researcher_inputs.rs | 7 ++++ src/external_agent/tests.rs | 24 ++++++++++++- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/external_agent.rs b/src/external_agent.rs index cd41b31e..5dfe1995 100644 --- a/src/external_agent.rs +++ b/src/external_agent.rs @@ -12127,6 +12127,7 @@ struct EnvironmentPreflightReport { #[derive(Debug, Default)] struct EnvironmentPreflightProcessEvidence { started: bool, + unconfirmed_child: bool, process_tree: Option, side_effects: Option, fixed_version_probe_evidence: Option, @@ -12135,10 +12136,7 @@ struct EnvironmentPreflightProcessEvidence { impl EnvironmentPreflightProcessEvidence { #[cfg(target_os = "linux")] fn may_have_live_child(&self) -> bool { - self.started - && !self - .process_tree - .is_some_and(ProcessTreeEvidence::is_verified_empty) + self.unconfirmed_child } fn record_output(&mut self, output: &ProcessOutput) { @@ -12195,9 +12193,18 @@ impl EnvironmentPreflightProcessEvidence { ), false, ), + ProcessRunError::SetupTimeout { .. } | ProcessRunError::TeeConflict { .. } => { + // Preserve the attempted containment in reports without implying + // that this pre-release refusal created a possibly live child. + self.record_containment( + ProcessTreeEvidence::Unverified(ContainmentBackend::SystemdUserService), + SideEffectConfinementEvidence::Unverified( + SideEffectConfinementProfileKind::ExternalCodex, + ), + false, + ); + } ProcessRunError::Cancelled { evidence: None, .. } - | ProcessRunError::SetupTimeout { .. } - | ProcessRunError::TeeConflict { .. } | ProcessRunError::Spawn { .. } | ProcessRunError::ContainmentUnavailable { .. } | ProcessRunError::EnvironmentFailure { @@ -12213,6 +12220,16 @@ impl EnvironmentPreflightProcessEvidence { process_tree: ProcessTreeEvidence, side_effects: SideEffectConfinementEvidence, verified: bool, + ) { + self.unconfirmed_child |= !process_tree.is_verified_empty(); + self.record_containment(process_tree, side_effects, verified); + } + + fn record_containment( + &mut self, + process_tree: ProcessTreeEvidence, + side_effects: SideEffectConfinementEvidence, + verified: bool, ) { let retained_verified = self .process_tree diff --git a/src/external_agent/executor/launch_lifecycle/tests.rs b/src/external_agent/executor/launch_lifecycle/tests.rs index ebc6220d..c70c9fc2 100644 --- a/src/external_agent/executor/launch_lifecycle/tests.rs +++ b/src/external_agent/executor/launch_lifecycle/tests.rs @@ -317,21 +317,41 @@ fn production_launch_lifecycle_preflight_refusals_preserve_live_child_evidence() let _socket = CamAuthoritySocketTestGuard::install(None); for kind in ["setup-timeout", "tee-conflict"] { - for earlier_live_child in [false, true] { + for earlier_verified in [None, Some(true), Some(false)] { + let earlier_live_child = earlier_verified == Some(false); let (_guard, mut authority, registry) = fixture(); let (credentials, handle) = construct(&mut authority); let mut evidence = EnvironmentPreflightProcessEvidence::default(); - if earlier_live_child { + if let Some(verified) = earlier_verified { evidence.record( - ProcessTreeEvidence::Unverified(ContainmentBackend::SystemdUserService), - SideEffectConfinementEvidence::Unverified( - SideEffectConfinementProfileKind::ExternalGrok, - ), - false, + if verified { + ProcessTreeEvidence::VerifiedEmpty(ContainmentBackend::SystemdUserService) + } else { + ProcessTreeEvidence::Unverified(ContainmentBackend::SystemdUserService) + }, + if verified { + SideEffectConfinementEvidence::Verified( + SideEffectConfinementProfileKind::ExternalGrok, + ) + } else { + SideEffectConfinementEvidence::Unverified( + SideEffectConfinementProfileKind::ExternalGrok, + ) + }, + verified, ); } evidence.record_error(&pre_release_error(kind)); - assert_eq!(evidence.started, earlier_live_child); + assert!(evidence.started); + assert_eq!( + evidence.process_tree, + Some(ProcessTreeEvidence::Unverified( + ContainmentBackend::SystemdUserService + )) + ); + assert!(evidence + .side_effects + .is_some_and(|effects| !effects.is_verified())); assert_eq!(evidence.may_have_live_child(), earlier_live_child); if evidence.may_have_live_child() { authority.launch_custody().unwrap().exit_unknown(); diff --git a/src/external_agent/researcher_inputs.rs b/src/external_agent/researcher_inputs.rs index e1dcb0b2..4bccf459 100644 --- a/src/external_agent/researcher_inputs.rs +++ b/src/external_agent/researcher_inputs.rs @@ -155,6 +155,13 @@ pub(super) fn prepare_source_inputs( .stdout .run_metadata .environment_preflight_process_started, + unconfirmed_child: report + .stdout + .run_metadata + .environment_preflight_process_started + && !report + .process_tree + .is_some_and(ProcessTreeEvidence::is_verified_empty), process_tree: report.process_tree, side_effects: report.side_effects, fixed_version_probe_evidence: report diff --git a/src/external_agent/tests.rs b/src/external_agent/tests.rs index 2223c381..85fb48ea 100644 --- a/src/external_agent/tests.rs +++ b/src/external_agent/tests.rs @@ -11118,7 +11118,7 @@ fn environment_preflight_process_errors_retain_fail_closed_containment_evidence( assert!(!report.environment_preflight_quiescence_verified()); } - let pre_spawn_errors = vec![ + let pre_release_errors = vec![ ProcessRunError::TeeConflict { label: "codex version preflight".to_string(), stdout: PathBuf::from("injected-stdout"), @@ -11130,6 +11130,28 @@ fn environment_preflight_process_errors_retain_fail_closed_containment_evidence( phase: "injected pre-release setup", source: std::io::Error::other("injected setup timeout"), }, + ]; + for error in pre_release_errors { + assert!(process_run_error_definitely_before_process_start(&error)); + let mut evidence = EnvironmentPreflightProcessEvidence::default(); + evidence.record_error(&error); + assert!(evidence.started); + assert!(!evidence.unconfirmed_child); + assert_eq!( + evidence.process_tree, + Some(ProcessTreeEvidence::Unverified( + ContainmentBackend::SystemdUserService + )) + ); + assert_eq!( + evidence.side_effects, + Some(SideEffectConfinementEvidence::Unverified( + SideEffectConfinementProfileKind::ExternalCodex + )) + ); + } + + let pre_spawn_errors = vec![ ProcessRunError::Spawn { label: "codex version preflight".to_string(), command: "codex --version".to_string(),