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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion account-manager/core/src/providers/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<PathBuf> {
Vec::new()
}

fn detect(&self) -> InstallState {
InstallState::Unknown
}

fn list_accounts(&self) -> Result<Vec<Account>> {
Ok(Vec::new())
}

fn activation_mechanism(&self) -> ActivationMechanism {
ActivationMechanism::LaunchEnvironment
}

fn launch_spec(&self, _account: &StoredAccountMetadata) -> Result<LaunchSpec> {
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(),
Expand Down Expand Up @@ -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!(
Expand All @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions account-manager/core/src/providers/gemini_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions account-manager/core/src/providers/grok_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
242 changes: 242 additions & 0 deletions account-manager/core/src/providers/launch_lifecycle.rs
Original file line number Diff line number Diff line change
@@ -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<SecretRef>,
pub environment: Vec<CredentialEnvironmentInput>,
/// Monotonic admission deadline within this process, not a persisted expiry.
pub deadline: Option<Instant>,
pub phase: LaunchPhase,
pub revoked: bool,
}

/// Observation and pre-spawn revocation capability, without child custody.
#[derive(Clone)]
pub struct LaunchLifecycleHandle(Arc<Mutex<LaunchLifecycleView>>);

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<String>,
operation_id: impl Into<String>,
binding: SelectedAccountBinding,
deadline: Option<Instant>,
) -> Result<Self> {
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<Box<dyn CredentialStore>>,
) -> Result<LeasedChild> {
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;
Loading
Loading