diff --git a/account-manager/core/src/providers/cursor.rs b/account-manager/core/src/providers/cursor.rs index 72428a68..84e96a73 100644 --- a/account-manager/core/src/providers/cursor.rs +++ b/account-manager/core/src/providers/cursor.rs @@ -806,6 +806,7 @@ impl ProviderAdapter for CursorAdapter { validate_managed_cursor_home(&home, true)?; Ok(LaunchSpec::new(self.program()) .current_dir(self.resolved_working_directory()?) + .credential_environment_inputs(["CURSOR_API_KEY"]) .set_plain_env("HOME", home.as_os_str()) .set_plain_env("CURSOR_CONFIG_DIR", home.join(".cursor").as_os_str()) .set_plain_env("AGENT_CLI_CREDENTIAL_STORE", "file") @@ -866,11 +867,48 @@ impl ProviderAdapter for CursorAdapter { #[cfg(test)] mod tests { + use super::super::{launch_spec_for, LaunchEnvironment}; use super::ProviderAdapter; use super::*; use std::fs; use std::path::Path; + struct DeclaredLaunch(LaunchSpec); + + impl ProviderAdapter for DeclaredLaunch { + fn id(&self) -> &'static str { + PROVIDER_ID + } + + fn descriptor(&self) -> ProviderDescriptor { + unreachable!("only launch declaration is exercised") + } + + fn config_paths(&self) -> Vec { + Vec::new() + } + + fn detect(&self) -> InstallState { + InstallState::Unknown + } + + fn list_accounts(&self) -> Result> { + Ok(Vec::new()) + } + + fn activation_mechanism(&self) -> ActivationMechanism { + ActivationMechanism::LaunchEnvironment + } + + fn launch_spec(&self, _account: &StoredAccountMetadata) -> Result { + Ok(self.0.clone()) + } + + fn activate_account(&self, _account_id: &str) -> Result<()> { + Err(Error::NotImplemented("fixture activation")) + } + } + fn metadata(id: &str, state: StoredAccountState, selected: bool) -> StoredAccountMetadata { StoredAccountMetadata { id: id.to_string(), @@ -991,7 +1029,7 @@ mod tests { .is_none()); let complete = metadata("work", StoredAccountState::Complete, true); - let launch = adapter.launch_spec(&complete).expect("launch spec"); + let launch = launch_spec_for(&adapter, &complete).expect("launch spec"); let environment = launch.plain_environment(); let managed_home = data.path().join("accounts").join("cursor").join("work"); assert_eq!( @@ -1007,6 +1045,19 @@ mod tests { ); assert_eq!(launch.environment_removals(), vec!["CURSOR_API_KEY"]); assert!(file_store_auth_json(&managed_home).is_file()); + + let mut unconfined = launch; + unconfined.environment.retain(|entry| { + !matches!(entry, LaunchEnvironment::Remove { name } if name == "CURSOR_API_KEY") + }); + let error = launch_spec_for(&DeclaredLaunch(unconfined), &complete) + .err() + .expect("inherited credential input must be cleared"); + assert!(matches!( + error, + Error::ConfigWrite { reason, .. } + if reason == "inherited credential input was not removed or replaced from the selected store" + )); } #[test] diff --git a/account-manager/core/src/providers/gemini_cli.rs b/account-manager/core/src/providers/gemini_cli.rs index 4f6d2f52..722fea00 100644 --- a/account-manager/core/src/providers/gemini_cli.rs +++ b/account-manager/core/src/providers/gemini_cli.rs @@ -668,6 +668,7 @@ impl ProviderAdapter for GeminiCliAdapter { .unwrap_or_else(|| PathBuf::from("gemini")), ) .current_dir(cwd) + .credential_environment_inputs(OAUTH_REMOVED_AUTH_ENVIRONMENT.iter().copied()) .set_plain_env(HOME_ENV, home.as_os_str()) .set_plain_env(GCA_ENV, "true"); for name in OAUTH_REMOVED_AUTH_ENVIRONMENT { @@ -684,6 +685,9 @@ impl ProviderAdapter for GeminiCliAdapter { .unwrap_or_else(|| PathBuf::from("gemini")), ) .current_dir(cwd) + .credential_environment_inputs( + std::iter::once(API_KEY_ENV).chain(REMOVED_AUTH_ENVIRONMENT.iter().copied()), + ) .set_secret_env(API_KEY_ENV); for name in REMOVED_AUTH_ENVIRONMENT { spec = spec.remove_env(*name); diff --git a/account-manager/core/src/providers/grok_cli.rs b/account-manager/core/src/providers/grok_cli.rs index 9ca165b3..6a12031e 100644 --- a/account-manager/core/src/providers/grok_cli.rs +++ b/account-manager/core/src/providers/grok_cli.rs @@ -1304,6 +1304,7 @@ impl ProviderAdapter for GrokCliAdapter { Ok(LaunchSpec::new(self.program()) .arg("--no-auto-update") .current_dir(self.resolved_working_directory()?) + .credential_environment_inputs(["GROK_AUTH_PATH", "XAI_API_KEY"]) .set_plain_env("GROK_HOME", home.as_os_str()) .remove_env("GROK_AUTH_PATH") .remove_env("XAI_API_KEY")) diff --git a/account-manager/core/src/providers/launch_lifecycle.rs b/account-manager/core/src/providers/launch_lifecycle.rs new file mode 100644 index 00000000..c145a7c0 --- /dev/null +++ b/account-manager/core/src/providers/launch_lifecycle.rs @@ -0,0 +1,242 @@ +//! One-shot launch admission and reference-only finalization evidence. +//! +//! This is an in-process operation, not a remote authority operation or a +//! credential rotation service. Account incarnations are its material generation +//! tokens. The selected-use lease protects that generation through child exit. + +use std::process::ExitStatus; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use crate::account_authority::{SelectedAccountBinding, StoredAccountRegistry}; +use crate::error::{Error, Result}; +use crate::storage::{CredentialStore, SecretRef}; + +use super::leased_launch::LeasedChild; +use super::{ + launch_spec_for, spawn_command, spawn_launch_with, LaunchEnvironment, ProviderAdapter, +}; + +/// Closed finalization reasons. An unconfirmed exit is never a finalization. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchFinalization { + Refused, + SpawnFailed, + Exited, + AbnormalExit, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchPhase { + Prepared, + Constructing, + Running, + ExitUnknown, + Finalized(LaunchFinalization), +} + +/// Names and sources only; no inherited or resolved environment values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CredentialEnvironmentInput { + SelectedCredential(String), + Removed(String), +} + +/// Public evidence for one run/operation and exact selected incarnation. +/// Contains no command arguments, home contents, store handles, or material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchLifecycleView { + pub run_id: String, + pub operation_id: String, + pub binding: SelectedAccountBinding, + pub credential: Option, + pub environment: Vec, + /// Monotonic admission deadline within this process, not a persisted expiry. + pub deadline: Option, + pub phase: LaunchPhase, + pub revoked: bool, +} + +/// Observation and pre-spawn revocation capability, without child custody. +#[derive(Clone)] +pub struct LaunchLifecycleHandle(Arc>); + +impl LaunchLifecycleHandle { + pub fn snapshot(&self) -> LaunchLifecycleView { + self.0.lock().expect("launch lifecycle lock").clone() + } + + /// Revoke pending admission. Once spawned, use child cancellation and wait + /// for confirmed exit; revocation alone cannot release material custody. + pub fn revoke_before_spawn(&self) -> Result<()> { + let mut view = self.0.lock().expect("launch lifecycle lock"); + match view.phase { + LaunchPhase::Prepared | LaunchPhase::Constructing => { + view.revoked = true; + Ok(()) + } + _ => Err(refused("launch admission has already completed")), + } + } +} + +/// A non-cloneable, single-use launch operation. Dropping it before spawn +/// finalizes refusal; only its leased child can finalize after spawn. +#[must_use = "launch admission must be spawned or dropped"] +pub struct LaunchLifecycle { + handle: LaunchLifecycleHandle, +} + +impl LaunchLifecycle { + pub fn new( + run_id: impl Into, + operation_id: impl Into, + binding: SelectedAccountBinding, + deadline: Option, + ) -> Result { + let run_id = run_id.into(); + let operation_id = operation_id.into(); + if [&run_id, &operation_id] + .iter() + .any(|id| id.is_empty() || id.len() > 256 || id.chars().any(char::is_control)) + { + return Err(refused( + "launch requires bounded run and operation identifiers", + )); + } + Ok(Self { + handle: LaunchLifecycleHandle(Arc::new(Mutex::new(LaunchLifecycleView { + run_id, + operation_id, + binding, + credential: None, + environment: Vec::new(), + deadline, + phase: LaunchPhase::Prepared, + revoked: false, + }))), + }) + } + + pub fn handle(&self) -> LaunchLifecycleHandle { + self.handle.clone() + } + + /// Resolve the adapter and selected store only inside this operation. + /// Admission is checked both before resolution and at the actual spawn edge. + /// Revocation and spawn serialize on the same lock. Expiry gates admission; + /// it does not assert that an already-running child has stopped. + pub fn spawn( + self, + registry: &StoredAccountRegistry, + adapter: &dyn ProviderAdapter, + credential_store: impl FnOnce() -> Result>, + ) -> Result { + 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.clone())); + } + let lease = registry.acquire_selected_use(&binding)?; + let account = registry.complete(adapter.id(), &binding.account_id)?; + let spec = launch_spec_for(adapter, &account)?; + { + let mut view = self.handle.0.lock().expect("launch lifecycle lock"); + view.credential = spec + .requires_credential() + .then(|| SecretRef::for_account(&binding.provider_id, &binding.account_id)); + view.environment = spec + .environment + .iter() + .filter_map(|entry| match entry { + LaunchEnvironment::SetSecret { name } => { + Some(CredentialEnvironmentInput::SelectedCredential(name.clone())) + } + LaunchEnvironment::Remove { name } => { + Some(CredentialEnvironmentInput::Removed(name.clone())) + } + LaunchEnvironment::SetPlain { .. } => None, + }) + .collect(); + } + let store = spec + .requires_credential() + .then(credential_store) + .transpose()?; + let child = spawn_launch_with(spec, &account, store.as_deref(), |command| { + let mut view = self.handle.0.lock().expect("launch lifecycle lock"); + self.check_admission(&view)?; + match spawn_command(command, &binding.provider_id) { + Ok(child) => { + view.phase = LaunchPhase::Running; + Ok(child) + } + Err(error) => { + view.phase = LaunchPhase::Finalized(LaunchFinalization::SpawnFailed); + Err(error) + } + } + })?; + Ok(LeasedChild::new(child, binding, lease, self)) + } + + fn check_admission(&self, view: &LaunchLifecycleView) -> Result<()> { + if view.revoked { + return Err(refused("launch admission was revoked")); + } + if view + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + return Err(refused("launch admission expired")); + } + Ok(()) + } + + pub(super) fn confirmed_exit(&self, status: ExitStatus, cancelled: bool) { + let mut view = self.handle.0.lock().expect("launch lifecycle lock"); + if matches!(view.phase, LaunchPhase::Running | LaunchPhase::ExitUnknown) { + view.phase = LaunchPhase::Finalized(if cancelled { + LaunchFinalization::Cancelled + } else if status.success() { + LaunchFinalization::Exited + } else { + LaunchFinalization::AbnormalExit + }); + } + } + + pub(super) fn exit_unknown(&self) { + let mut view = self.handle.0.lock().expect("launch lifecycle lock"); + if view.phase == LaunchPhase::Running { + view.phase = LaunchPhase::ExitUnknown; + } + } +} + +impl Drop for LaunchLifecycle { + fn drop(&mut self) { + let mut view = self.handle.0.lock().expect("launch lifecycle lock"); + if matches!( + view.phase, + LaunchPhase::Prepared | LaunchPhase::Constructing + ) { + view.phase = LaunchPhase::Finalized(LaunchFinalization::Refused); + } + } +} + +fn refused(reason: &'static str) -> Error { + Error::ConfigWrite { + provider: "launch-lifecycle".into(), + reason: reason.into(), + } +} + +#[cfg(test)] +mod tests; diff --git a/account-manager/core/src/providers/launch_lifecycle/tests.rs b/account-manager/core/src/providers/launch_lifecycle/tests.rs new file mode 100644 index 00000000..1cae4ca7 --- /dev/null +++ b/account-manager/core/src/providers/launch_lifecycle/tests.rs @@ -0,0 +1,486 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use super::*; +use crate::model::{ + Account, AuthKind, InstallState, ProviderDescriptor, StoredAccountMaterial, + StoredAccountMetadata, +}; +use crate::providers::{ActivationMechanism, LaunchSpec}; +use crate::storage::Secret; + +const PROVIDER: &str = "lifecycle-probe"; +const SECRET: &[u8] = b"FAKE-lifecycle-selected-material"; +const SECRET_ENV: &str = "CAM_LIFECYCLE_SECRET"; +const REMOVED_ENV: &str = "CAM_LIFECYCLE_REMOVE"; +const CHILD_TEST: &str = "providers::launch_lifecycle::tests::lifecycle_child_probe"; + +struct Fixture { + directory: tempfile::TempDir, + registry: StoredAccountRegistry, + binding: SelectedAccountBinding, + spec: LaunchSpec, + declarations: AtomicUsize, + refuse: bool, +} + +impl Fixture { + fn new() -> Self { + let directory = tempfile::tempdir().unwrap(); + let registry = StoredAccountRegistry::new(directory.path().join("accounts.json")); + for name in ["work", "other"] { + registry + .begin_add( + PROVIDER, + name, + name, + AuthKind::ApiKey, + StoredAccountMaterial::CredentialStore, + ) + .unwrap(); + registry.complete_add(PROVIDER, name).unwrap(); + } + registry.select_complete(PROVIDER, "work").unwrap(); + let binding = registry.selected_binding(PROVIDER).unwrap().unwrap(); + let spec = LaunchSpec::new(std::env::current_exe().unwrap()) + .current_dir(directory.path()) + .arg("--exact") + .arg(CHILD_TEST) + .arg("--nocapture") + .set_plain_env("CAM_LIFECYCLE_DIRECTORY", directory.path()) + .credential_environment_inputs([SECRET_ENV, REMOVED_ENV]) + .set_secret_env(SECRET_ENV) + .remove_env(REMOVED_ENV); + Self { + directory, + registry, + binding, + spec, + declarations: AtomicUsize::new(0), + refuse: false, + } + } + + fn guard(&self) -> LaunchLifecycle { + LaunchLifecycle::new("run-725", "operation-1", self.binding.clone(), None).unwrap() + } + + fn busy(&self) { + assert!(matches!( + self.registry.begin_delete(PROVIDER, "work"), + Err(Error::AccountAuthorityBusy { .. }) + )); + } + + fn released(&self) { + self.registry.select_complete(PROVIDER, "other").unwrap(); + self.registry.begin_delete(PROVIDER, "work").unwrap(); + } +} + +impl ProviderAdapter for Fixture { + fn id(&self) -> &'static str { + PROVIDER + } + fn descriptor(&self) -> ProviderDescriptor { + unimplemented!() + } + fn config_paths(&self) -> Vec { + Vec::new() + } + fn detect(&self) -> InstallState { + InstallState::Unknown + } + fn list_accounts(&self) -> Result> { + Ok(Vec::new()) + } + fn activation_mechanism(&self) -> ActivationMechanism { + ActivationMechanism::LaunchEnvironment + } + fn activate_account(&self, _: &str) -> Result<()> { + unimplemented!() + } + fn launch_spec(&self, account: &StoredAccountMetadata) -> Result { + self.declarations.fetch_add(1, Ordering::SeqCst); + self.busy(); + assert_eq!( + account.account_incarnation, + self.binding.account_incarnation + ); + if self.refuse { + return Err(Error::NotImplemented("fixture refusal")); + } + Ok(self.spec.clone()) + } +} + +// The factory owns only synthetic material, never an OS credential backend. +struct OwnedStore { + fixture: Arc, + handle: LaunchLifecycleHandle, + revoke: bool, + missing: bool, + reads: Arc, +} + +impl CredentialStore for OwnedStore { + fn id(&self) -> &'static str { + "synthetic-lifecycle" + } + fn is_available(&self) -> bool { + true + } + fn put(&self, _: &SecretRef, _: &Secret) -> Result<()> { + unimplemented!() + } + fn delete(&self, _: &SecretRef) -> Result<()> { + unimplemented!() + } + fn get(&self, key: &SecretRef) -> Result> { + assert_eq!(*key, SecretRef::for_account(PROVIDER, "work")); + assert_eq!(self.handle.snapshot().phase, LaunchPhase::Constructing); + self.fixture.busy(); + self.reads.fetch_add(1, Ordering::SeqCst); + if self.revoke { + self.handle.revoke_before_spawn().unwrap(); + } + Ok((!self.missing).then(|| Secret::new(SECRET.to_vec()))) + } +} + +fn spawn( + fixture: &Arc, + guard: LaunchLifecycle, + revoke: bool, + missing: bool, + reads: Arc, +) -> Result { + let handle = guard.handle(); + guard.spawn(&fixture.registry, fixture.as_ref(), || { + fixture.busy(); + Ok(Box::new(OwnedStore { + fixture: Arc::clone(fixture), + handle, + revoke, + missing, + reads, + })) + }) +} + +fn await_file(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(30); + while !path.exists() { + assert!(Instant::now() < deadline, "child barrier timed out"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn release_child(fixture: &Fixture, payload: &[u8]) { + let pending = fixture.directory.path().join("release.pending"); + fs::write(&pending, payload).unwrap(); + fs::rename(pending, fixture.directory.path().join("release")).unwrap(); +} + +struct TestChild(LeasedChild); +impl Drop for TestChild { + fn drop(&mut self) { + let _ = self.0.cancel(); + } +} + +#[test] +fn lifecycle_child_probe() { + let Some(directory) = std::env::var_os("CAM_LIFECYCLE_DIRECTORY") else { + return; + }; + assert_eq!(std::env::var(SECRET_ENV).unwrap().as_bytes(), SECRET); + assert!(std::env::var_os(REMOVED_ENV).is_none()); + let directory = PathBuf::from(directory); + fs::write(directory.join("ready"), b"ready").unwrap(); + await_file(&directory.join("release")); + if fs::read(directory.join("release")).unwrap() == b"nonzero" { + panic!("synthetic abnormal exit"); + } +} + +#[test] +fn guard_refuses_stale_expired_and_revoked_binding() { + for refusal in [ + "revision", + "incarnation", + "deleted", + "provider", + "expiry", + "revoked", + "drop", + ] { + let fixture = Fixture::new(); + let mut binding = fixture.binding.clone(); + match refusal { + "revision" => fixture.registry.select_complete(PROVIDER, "other").unwrap(), + "incarnation" => { + binding.account_incarnation = "00000000000000000000000000000000".into() + } + "deleted" => { + fixture.registry.begin_delete(PROVIDER, "work").unwrap(); + } + "provider" => binding.provider_id = "different-provider".into(), + _ => {} + } + let guard = LaunchLifecycle::new( + "run", + "operation", + binding, + (refusal == "expiry").then(Instant::now), + ) + .unwrap(); + let handle = guard.handle(); + if refusal == "revoked" { + handle.revoke_before_spawn().unwrap(); + } + if refusal == "drop" { + drop(guard); + } else { + assert!(guard + .spawn(&fixture.registry, &fixture, || { + panic!("refused launch must not resolve a store") + }) + .is_err()); + } + assert_eq!( + handle.snapshot().phase, + LaunchPhase::Finalized(LaunchFinalization::Refused) + ); + assert_eq!(fixture.declarations.load(Ordering::SeqCst), 0); + } +} + +#[test] +fn guard_finalizes_resolution_and_spawn_failures() { + for failure in ["adapter", "store", "missing", "spawn"] { + let mut fixture = Fixture::new(); + fixture.refuse = failure == "adapter"; + if failure == "spawn" { + fixture.spec.program = fixture.directory.path().join("missing-tool"); + } + let fixture = Arc::new(fixture); + let guard = fixture.guard(); + let handle = guard.handle(); + let result = if failure == "store" { + guard.spawn(&fixture.registry, fixture.as_ref(), || { + Err(Error::NotImplemented("fixture store")) + }) + } else { + spawn( + &fixture, + guard, + false, + failure == "missing", + Arc::new(AtomicUsize::new(0)), + ) + }; + assert!(result.is_err()); + let reason = if failure == "spawn" { + LaunchFinalization::SpawnFailed + } else { + LaunchFinalization::Refused + }; + assert_eq!(handle.snapshot().phase, LaunchPhase::Finalized(reason)); + assert!(!format!("{:?}", handle.snapshot()).contains(std::str::from_utf8(SECRET).unwrap())); + fixture.released(); + } +} + +#[test] +fn guard_tracks_exit_and_reference_only_metadata() { + for nonzero in [false, true] { + let fixture = Arc::new(Fixture::new()); + let guard = fixture.guard(); + let handle = guard.handle(); + let reads = Arc::new(AtomicUsize::new(0)); + let mut child = + TestChild(spawn(&fixture, guard, false, false, Arc::clone(&reads)).unwrap()); + await_file(&fixture.directory.path().join("ready")); + assert_eq!(reads.load(Ordering::SeqCst), 1); + let view = handle.snapshot(); + assert_eq!(view.run_id, "run-725"); + assert_eq!(view.operation_id, "operation-1"); + assert_eq!(view.binding, fixture.binding); + assert_eq!( + view.credential, + Some(SecretRef::for_account(PROVIDER, "work")) + ); + assert_eq!( + view.environment, + vec![ + CredentialEnvironmentInput::SelectedCredential(SECRET_ENV.into()), + CredentialEnvironmentInput::Removed(REMOVED_ENV.into()), + ] + ); + assert_eq!(child.0.lifecycle().snapshot(), view); + assert!(handle.revoke_before_spawn().is_err()); + assert!(child.0.try_wait().unwrap().is_none()); + fixture.busy(); + release_child( + &fixture, + if nonzero { + b"nonzero".as_slice() + } else { + b"ok" + }, + ); + assert_eq!(child.0.wait().unwrap().success(), !nonzero); + let finalized = handle.snapshot(); + assert_eq!( + finalized.phase, + LaunchPhase::Finalized(if nonzero { + LaunchFinalization::AbnormalExit + } else { + LaunchFinalization::Exited + }) + ); + child.0.wait().unwrap(); + child.0.try_wait().unwrap(); + child.0.cancel().unwrap(); + drop(child); + assert_eq!(handle.snapshot(), finalized); + fixture.released(); + } +} + +#[test] +fn guard_cancellation_finalizes_only_confirmed_exit() { + let fixture = Arc::new(Fixture::new()); + let guard = fixture.guard(); + let handle = guard.handle(); + let mut child = + TestChild(spawn(&fixture, guard, false, false, Arc::new(AtomicUsize::new(0))).unwrap()); + await_file(&fixture.directory.path().join("ready")); + fixture.busy(); + child.0.cancel().unwrap(); + assert_eq!( + handle.snapshot().phase, + LaunchPhase::Finalized(LaunchFinalization::Cancelled) + ); + child.0.wait().unwrap(); + drop(child); + assert_eq!( + handle.snapshot().phase, + LaunchPhase::Finalized(LaunchFinalization::Cancelled) + ); + fixture.released(); +} + +#[test] +fn guard_rechecks_revocation_after_material_resolution() { + let fixture = Arc::new(Fixture::new()); + let guard = fixture.guard(); + let handle = guard.handle(); + let reads = Arc::new(AtomicUsize::new(0)); + assert!(spawn(&fixture, guard, true, false, Arc::clone(&reads)).is_err()); + assert_eq!(reads.load(Ordering::SeqCst), 1); + assert_eq!( + handle.snapshot().phase, + LaunchPhase::Finalized(LaunchFinalization::Refused) + ); + assert!(!fixture.directory.path().join("ready").exists()); + fixture.released(); +} + +#[test] +fn guard_rejects_unconfined_inherited_credential_inputs() { + for failure in ["missing", "plain", "case-shadow"] { + let mut fixture = Fixture::new(); + if failure == "case-shadow" { + fixture.spec = fixture + .spec + .set_plain_env("cam_lifecycle_secret", "public-value"); + } else { + fixture.spec = fixture + .spec + .credential_environment_inputs(["CAM_UNCONFINED"]); + } + if failure == "plain" { + fixture.spec = fixture.spec.set_plain_env("CAM_UNCONFINED", "public-value"); + } + let guard = fixture.guard(); + let handle = guard.handle(); + assert!(guard + .spawn(&fixture.registry, &fixture, || panic!( + "invalid environment must not resolve material" + )) + .is_err()); + assert_eq!( + handle.snapshot().phase, + LaunchPhase::Finalized(LaunchFinalization::Refused) + ); + fixture.released(); + } +} + +#[test] +fn guard_confines_inherited_synthetic_environment() { + const MARKER: &str = "CAM_LIFECYCLE_PARENT_PROBE"; + const INHERITED: &str = "FAKE-inherited-material"; + if std::env::var_os(MARKER).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("providers::launch_lifecycle::tests::guard_confines_inherited_synthetic_environment") + .env(MARKER, "1") + .env(SECRET_ENV, INHERITED) + .env(REMOVED_ENV, INHERITED) + .status().unwrap(); + assert!(status.success()); + return; + } + let fixture = Arc::new(Fixture::new()); + let mut child = TestChild( + spawn( + &fixture, + fixture.guard(), + false, + false, + Arc::new(AtomicUsize::new(0)), + ) + .unwrap(), + ); + await_file(&fixture.directory.path().join("ready")); + release_child(&fixture, b"ok"); + assert!(child.0.wait().unwrap().success()); + assert_eq!(std::env::var(SECRET_ENV).unwrap(), INHERITED); + assert_eq!(std::env::var(REMOVED_ENV).unwrap(), INHERITED); + fixture.released(); +} + +#[cfg(unix)] +#[test] +fn guard_retains_custody_when_exit_is_unknown() { + let fixture = Arc::new(Fixture::new()); + let guard = fixture.guard(); + let handle = guard.handle(); + let mut child = spawn(&fixture, guard, false, false, Arc::new(AtomicUsize::new(0))).unwrap(); + await_file(&fixture.directory.path().join("ready")); + release_child(&fixture, b"ok"); + let pid = child.id() as libc::pid_t; + let mut status = 0; + loop { + // SAFETY: pid is this fixture's exact child; status points to writable memory. + if unsafe { libc::waitpid(pid, &mut status, 0) } == pid { + break; + } + assert_eq!( + std::io::Error::last_os_error().kind(), + std::io::ErrorKind::Interrupted + ); + } + assert!(child.wait().is_err()); + assert!(child.cancel().is_err()); + assert_eq!(handle.snapshot().phase, LaunchPhase::ExitUnknown); + fixture.busy(); + drop(child); + assert_eq!(handle.snapshot().phase, LaunchPhase::ExitUnknown); + fixture.busy(); +} diff --git a/account-manager/core/src/providers/leased_launch.rs b/account-manager/core/src/providers/leased_launch.rs index fbb49bd7..fc8c8465 100644 --- a/account-manager/core/src/providers/leased_launch.rs +++ b/account-manager/core/src/providers/leased_launch.rs @@ -7,7 +7,8 @@ use crate::account_authority::{SelectedAccountBinding, SelectedUseLease, StoredA use crate::error::{Error, Result}; use crate::storage::CredentialStore; -use super::{launch_spec_for, spawn_launch, ProviderAdapter}; +use super::launch_lifecycle::{LaunchLifecycle, LaunchLifecycleHandle}; +use super::ProviderAdapter; /// A child and its selected-account use lease, transferred together to a reaper. /// @@ -19,9 +20,30 @@ pub struct LeasedChild { child: Child, binding: SelectedAccountBinding, lease: Option, + lifecycle: LaunchLifecycle, + cancellation_requested: bool, } impl LeasedChild { + pub(super) fn new( + child: Child, + binding: SelectedAccountBinding, + lease: SelectedUseLease, + lifecycle: LaunchLifecycle, + ) -> Self { + Self { + child, + binding, + lease: Some(lease), + lifecycle, + cancellation_requested: false, + } + } + + pub fn lifecycle(&self) -> LaunchLifecycleHandle { + self.lifecycle.handle() + } + pub fn id(&self) -> u32 { self.child.id() } @@ -32,28 +54,53 @@ impl LeasedChild { /// Wait for exit, retaining the lease if the wait fails. pub fn wait(&mut self) -> io::Result { - let status = self.child.wait()?; + let status = self + .child + .wait() + .inspect_err(|_| self.lifecycle.exit_unknown())?; + self.lifecycle + .confirmed_exit(status, self.cancellation_requested); self.lease.take(); Ok(status) } /// Poll for exit, retaining the lease while alive or if the poll fails. pub fn try_wait(&mut self) -> io::Result> { - let status = self.child.try_wait()?; - if status.is_some() { + let status = self + .child + .try_wait() + .inspect_err(|_| self.lifecycle.exit_unknown())?; + if let Some(status) = status { + self.lifecycle + .confirmed_exit(status, self.cancellation_requested); self.lease.take(); } Ok(status) } + + /// Request termination of this exact child and finalize cancellation only + /// after wait confirms exit. A failed kill or wait retains the lease. + pub fn cancel(&mut self) -> io::Result { + if let Some(status) = self.try_wait()? { + return Ok(status); + } + self.child.kill()?; + self.cancellation_requested = true; + self.wait() + } } impl Drop for LeasedChild { fn drop(&mut self) { if let Some(lease) = self.lease.take() { - if !matches!(self.child.try_wait(), Ok(Some(_))) { + if let Ok(Some(status)) = self.child.try_wait() { + self.lifecycle + .confirmed_exit(status, self.cancellation_requested); + } else { // Child::drop does not wait. Keep the active-use barrier even // if custody is abandoned or a reaper cannot confirm exit. std::mem::forget(lease); + self.lifecycle.exit_unknown(); } } } @@ -70,22 +117,19 @@ pub fn spawn_selected_launch( binding: &SelectedAccountBinding, credential_store: impl FnOnce() -> Result>, ) -> Result { - if binding.provider_id != adapter.id() { - return Err(Error::UnknownProvider(binding.provider_id.clone())); - } - let lease = registry.acquire_selected_use(binding)?; - let account = registry.complete(adapter.id(), &binding.account_id)?; - let spec = launch_spec_for(adapter, &account)?; - let store = spec - .requires_credential() - .then(credential_store) - .transpose()?; - let child = spawn_launch(spec, &account, store.as_deref())?; - Ok(LeasedChild { - child, - binding: binding.clone(), - lease: Some(lease), - }) + let mut bytes = [0u8; 16]; + getrandom::getrandom(&mut bytes).map_err(|_| Error::ConfigWrite { + provider: binding.provider_id.clone(), + reason: "could not allocate launch operation identity".into(), + })?; + let operation_id: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + LaunchLifecycle::new( + format!("local-process-{}", std::process::id()), + operation_id, + binding.clone(), + None, + )? + .spawn(registry, adapter, credential_store) } #[cfg(test)] diff --git a/account-manager/core/src/providers/mod.rs b/account-manager/core/src/providers/mod.rs index 295bf09e..1ed4afa3 100644 --- a/account-manager/core/src/providers/mod.rs +++ b/account-manager/core/src/providers/mod.rs @@ -11,7 +11,7 @@ //! `[unknown]` not yet established. Never upgrade a marker without evidence. use std::collections::{BTreeSet, HashSet}; -use std::ffi::{OsStr, OsString}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -30,6 +30,7 @@ pub use crate::account_authority::{ pub mod leased_launch; pub use leased_launch::{spawn_selected_launch, LeasedChild}; +pub mod launch_lifecycle; pub mod observe; pub use observe::observe_selected_account; @@ -95,6 +96,7 @@ pub struct LaunchSpec { args: Vec, environment: Vec, working_directory: Option, + credential_inputs: Vec, } impl LaunchSpec { @@ -104,6 +106,7 @@ impl LaunchSpec { args: Vec::new(), environment: Vec::new(), working_directory: None, + credential_inputs: Vec::new(), } } @@ -147,6 +150,17 @@ impl LaunchSpec { .any(|entry| matches!(entry, LaunchEnvironment::SetSecret { .. })) } + /// Declare inherited credential inputs that must be removed or replaced + /// from the selected store. Values are never read from the parent process. + pub fn credential_environment_inputs( + mut self, + names: impl IntoIterator>, + ) -> Self { + self.credential_inputs + .extend(names.into_iter().map(Into::into)); + self + } + /// Non-secret `SetPlain` environment mutations declared for a launch. pub fn plain_environment(&self) -> Vec<(String, OsString)> { self.environment @@ -384,6 +398,18 @@ pub fn spawn_launch( spec: LaunchSpec, account: &StoredAccountMetadata, credential_store: Option<&dyn CredentialStore>, +) -> Result { + spawn_launch_with(spec, account, credential_store, |command| { + spawn_command(command, &account.provider_id) + }) +} + +// The lifecycle supplies the final admission check at the actual spawn edge. +fn spawn_launch_with( + spec: LaunchSpec, + account: &StoredAccountMetadata, + credential_store: Option<&dyn CredentialStore>, + spawn: impl FnOnce(&mut Command) -> Result, ) -> Result { if account.state != StoredAccountState::Complete || !account.is_selected { return Err(Error::ConfigWrite { @@ -437,15 +463,13 @@ pub fn spawn_launch( } } + spawn(&mut command) +} + +fn spawn_command(command: &mut Command, provider: &str) -> Result { command.spawn().map_err(|error| Error::ConfigWrite { - provider: account.provider_id.clone(), - reason: format!( - "could not start {}: {error}", - spec.program - .file_name() - .unwrap_or_else(|| OsStr::new("provider tool")) - .to_string_lossy() - ), + provider: provider.to_string(), + reason: format!("could not start provider tool ({})", error.kind()), }) } @@ -641,7 +665,7 @@ fn validate_launch_environment(spec: &LaunchSpec, account: &StoredAccountMetadat | LaunchEnvironment::SetSecret { name } | LaunchEnvironment::Remove { name } => name, }; - if !environment_name_is_safe(name) || !names.insert(name) { + if !environment_name_is_safe(name) || !names.insert(name.to_ascii_uppercase()) { return Err(metadata_write_error( &account.provider_id, "adapter declared an invalid or duplicate environment variable", @@ -649,6 +673,20 @@ fn validate_launch_environment(spec: &LaunchSpec, account: &StoredAccountMetadat } } let expects_credential = account.material == StoredAccountMaterial::CredentialStore; + for name in &spec.credential_inputs { + if !environment_name_is_safe(name) + || !spec.environment.iter().any(|entry| match entry { + LaunchEnvironment::SetSecret { name: controlled } + | LaunchEnvironment::Remove { name: controlled } => controlled == name, + LaunchEnvironment::SetPlain { .. } => false, + }) + { + return Err(metadata_write_error( + &account.provider_id, + "inherited credential input was not removed or replaced from the selected store", + )); + } + } if spec.requires_credential() != expects_credential { return Err(metadata_write_error( &account.provider_id, diff --git a/account-manager/docs/MACO_INTEGRATION.md b/account-manager/docs/MACO_INTEGRATION.md index fb634a09..c4a5c26c 100644 --- a/account-manager/docs/MACO_INTEGRATION.md +++ b/account-manager/docs/MACO_INTEGRATION.md @@ -196,6 +196,32 @@ revocation. Reauthentication failure leaves a truthful recoverable state. ## 6. Frozen operation lifecycle +The in-process selected-launch path uses a one-shot +`providers::launch_lifecycle::LaunchLifecycle` operation. Its immutable public +view binds a run ID, operation ID, and exact `SelectedAccountBinding`; credential +references and environment input names contain no material. Callers with run +identity construct the guard explicitly. The compatibility +`spawn_selected_launch` entry assigns a fresh operation ID within its local +process run. This identity is not a durable remote operation handle. + +The guard acquires the selected-use lease before adapter declaration or store +resolution. Optional monotonic expiry and pre-spawn revocation are checked +before resolution and again at the spawn edge. Revocation and spawn share an +admission lock. Expiry governs admission, not an already-running child's +lifetime. Adapters declare inherited credential input names; each must be +removed or replaced from the selected store. Their existing home confinement, +authentication clearing, and child-only environment actions remain in force. +This is an explicit adapter contract, not a scan of all ambient credentials. + +Refusal, spawn failure, confirmed normal/nonzero exit, and confirmed cancellation +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 initial operation kind is `work-proposal`: one fresh proposal turn, with no provider-owned workspace mutation, nested orchestration, or independent review. The adapter must establish the provider's tool restrictions before advertising