diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 0b180f5a..a2f14ec9 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -28,10 +28,11 @@ use domain::sandbox::{compile_sandbox_plan, PathAccess, PathGrant, SandboxContex use domain::{ bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions, - Handoff, HandoffStore, ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NetworkPolicy, - NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, ProfileId, Project, ProjectPath, - ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, SessionId, - SessionKind, SessionStatus, SkillKind, TerminalSession, HANDOFF_SUMMARY_MAX_CHARS, + Handoff, HandoffStore, ManagedTempKind, ManifestEntry, MarkdownDoc, MemoryIndexEntry, + MemoryType, NetworkPolicy, NodeId, OrchestrationCapabilitySnapshot, PermissionProjector, + ProfileId, Project, ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, + ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, SkillKind, + TerminalSession, HANDOFF_SUMMARY_MAX_CHARS, }; use domain::live_state::WorkStatus; @@ -41,7 +42,10 @@ use crate::codex_permissions::{ }; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; -use crate::managed_temp::{ResolveManagedTemp, ResolveManagedTempInput}; +use crate::managed_temp::{ + AllocateManagedTemp, AllocateManagedTempInput, ReleaseManagedTemp, ReleaseManagedTempInput, + ResolveManagedTemp, ResolveManagedTempInput, +}; use crate::model_server::{ EnsureLocalModelServer, EnsureLocalModelServerInput, ModelServerUseGuard, }; @@ -1165,6 +1169,12 @@ pub struct ManagedTempExposureRequest { pub handles: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct LaunchManagedTemp { + handle: String, + path: String, +} + /// OS/runtime facts injected by the composition root (`app-tauri`) to materialise /// the **real** IdeA MCP server declaration in an agent's `.mcp.json` (cadrage v5 /// §2). Carrying these as **plain data** on [`LaunchAgentInput`] keeps @@ -1398,11 +1408,14 @@ fn enrich_sandbox_plan_with_rw_roots( } fn dedupe_paths(paths: impl IntoIterator) -> Vec { + dedupe_string_paths(paths.into_iter().map(|path| path.as_str().to_owned())) +} + +fn dedupe_string_paths(paths: impl IntoIterator) -> Vec { let mut roots = Vec::new(); for path in paths { - let root = path.as_str().to_owned(); - if !roots.iter().any(|existing| existing == &root) { - roots.push(root); + if !roots.iter().any(|existing| existing == &path) { + roots.push(path); } } roots.sort(); @@ -1562,6 +1575,11 @@ pub struct LaunchAgent { /// Resolves opaque managed-temp handles at launch time. A non-empty /// [`ManagedTempExposureRequest`] fails closed when this dependency is absent. managed_temp_resolver: Option>, + /// Allocates the primary launch-scoped managed temp directory. + managed_temp_allocator: Option>, + /// Releases the primary launch-scoped managed temp directory when launch fails + /// before registration. + managed_temp_releaser: Option>, /// Explicit intent for structured profiles when structured ports are absent. structured_routing_mode: StructuredRoutingMode, } @@ -1611,6 +1629,8 @@ impl LaunchAgent { local_model_server: None, secret_store: None, managed_temp_resolver: None, + managed_temp_allocator: None, + managed_temp_releaser: None, structured_routing_mode: StructuredRoutingMode::HumanPtyFallback, } } @@ -1648,6 +1668,18 @@ impl LaunchAgent { self } + /// Injects the allocator/releaser for the primary launch-scoped managed temp. + #[must_use] + pub fn with_launch_managed_temp( + mut self, + allocator: Arc, + releaser: Arc, + ) -> Self { + self.managed_temp_allocator = Some(allocator); + self.managed_temp_releaser = Some(releaser); + self + } + /// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu /// `# État du projet` (status + intent des autres agents) est injecté dans le /// convention file. Sans cet appel (cas legacy / tests existants), aucune section @@ -1832,6 +1864,39 @@ impl LaunchAgent { Ok(dedupe_paths(paths)) } + async fn allocate_launch_managed_temp( + &self, + project: &Project, + agent: &Agent, + ) -> Result, AppError> { + let Some(allocator) = self.managed_temp_allocator.as_ref() else { + return Ok(None); + }; + let output = allocator + .execute(AllocateManagedTempInput { + owner_project_id: Some(project.id), + kind: ManagedTempKind::Directory, + purpose: format!("agent launch temp {}", agent.id), + ttl_ms: None, + }) + .await?; + Ok(Some(LaunchManagedTemp { + handle: output.lease.handle.as_str().to_owned(), + path: output.lease.path.as_str().to_owned(), + })) + } + + async fn release_launch_managed_temp_best_effort(&self, temp: Option<&LaunchManagedTemp>) { + let (Some(releaser), Some(temp)) = (self.managed_temp_releaser.as_ref(), temp) else { + return; + }; + let _ = releaser + .execute(ReleaseManagedTempInput { + handle: temp.handle.clone(), + }) + .await; + } + /// Résout **best-effort** le handoff de reprise de la cellule (lot P7), calqué sur /// [`Self::resolve_memory`]. L'injection n'a lieu que si : /// - la cellule porte une `conversation_id` ([`LaunchAgentInput::conversation_id`]), @@ -2266,9 +2331,32 @@ impl LaunchAgent { // remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan, // comportement natif conservé (invariant produit). L'enforcer concret // (Landlock/Noop) est injecté côté PTY au composition root. - let managed_temp_writable_roots = self - .resolve_managed_temp_exposure(&input.managed_temp_exposure) + let launch_managed_temp = self + .allocate_launch_managed_temp(&input.project, &agent) .await?; + if let Some(temp) = &launch_managed_temp { + upsert_env(&mut spec.env, "TMPDIR", &temp.path); + upsert_env(&mut spec.env, "TMP", &temp.path); + upsert_env(&mut spec.env, "TEMP", &temp.path); + } + let exposed_managed_temp_roots = match self + .resolve_managed_temp_exposure(&input.managed_temp_exposure) + .await + { + Ok(roots) => roots, + Err(err) => { + self.release_launch_managed_temp_best_effort(launch_managed_temp.as_ref()) + .await; + return Err(err); + } + }; + let managed_temp_writable_roots = dedupe_string_paths( + launch_managed_temp + .as_ref() + .map(|temp| temp.path.clone()) + .into_iter() + .chain(exposed_managed_temp_roots.into_iter()), + ); spec.sandbox = compile_sandbox_plan( effective_permissions.as_ref(), &SandboxContext { @@ -2353,6 +2441,7 @@ impl LaunchAgent { &spec.env, spec.sandbox.as_ref(), structured_policy.as_ref(), + launch_managed_temp, local_model_server_guard, ) .await; @@ -2374,13 +2463,22 @@ impl LaunchAgent { append_codex_pty_model_overrides(&launch_profile, &mut spec); // 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere. - let handle = self.pty.spawn(spec.clone(), size).await?; + let handle = match self.pty.spawn(spec.clone(), size).await { + Ok(handle) => handle, + Err(err) => { + self.release_launch_managed_temp_best_effort(launch_managed_temp.as_ref()) + .await; + return Err(err.into()); + } + }; let session_id = handle.session_id; // 7. For the Stdin strategy, pipe the context once the PTY is live. if matches!(spec.context_plan, Some(ContextInjectionPlan::Stdin)) { if let Err(err) = self.pty.write(&handle, content.as_str().as_bytes()) { let _ = self.pty.kill(&handle).await; + self.release_launch_managed_temp_best_effort(launch_managed_temp.as_ref()) + .await; return Err(err.into()); } } @@ -2394,11 +2492,12 @@ impl LaunchAgent { size, ); session.status = SessionStatus::Running; - self.sessions.insert_in_project_with_model_server_guard( + self.sessions.insert_in_project_with_runtime_resources( input.project.id, handle, session.clone(), local_model_server_guard, + launch_managed_temp.map(|temp| temp.handle), ); self.events.publish(DomainEvent::AgentLaunched { @@ -2445,11 +2544,12 @@ impl LaunchAgent { env: &[(String, String)], sandbox: Option<&SandboxPlan>, structured_policy: Option<&StructuredProviderLaunchPolicy>, + launch_managed_temp: Option, model_server_guard: Option, ) -> Result { // Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`, // déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée. - let session = factory + let session = match factory .start( profile, prepared, @@ -2461,19 +2561,27 @@ impl LaunchAgent { structured_policy, ) .await - .map_err(|e| AppError::Process(e.to_string()))?; + { + Ok(session) => session, + Err(err) => { + self.release_launch_managed_temp_best_effort(launch_managed_temp.as_ref()) + .await; + return Err(AppError::Process(err.to_string())); + } + }; let session_id = session.id(); let node_id = node_id.unwrap_or_else(NodeId::new_random); // Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en // amont sur les deux registres). - structured.insert_in_project_with_model_server_guard( + structured.insert_in_project_with_runtime_resources( project_id, Arc::clone(&session), agent.id, node_id, model_server_guard, + launch_managed_temp.map(|temp| temp.handle), ); // ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ── diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index be8fbe8d..9af1b5af 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -15,6 +15,22 @@ use domain::{AgentId, IssueRef, NodeId, ProjectId, SessionId, SessionKind, Termi use crate::model_server::ModelServerUseGuard; +/// Runtime resources attached to a live PTY session and released when it stops. +pub struct RemovedPtySession { + /// Live PTY handle to kill. + pub handle: PtyHandle, + /// Launch-scoped managed-temp handle retained for release. + pub managed_temp_handle: Option, +} + +/// Runtime resources attached to a live structured session and released when it stops. +pub struct RemovedStructuredSession { + /// Live structured session to shut down. + pub session: Arc, + /// Launch-scoped managed-temp handle retained for release. + pub managed_temp_handle: Option, +} + /// Runtime family of a live agent session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LiveSessionKind { @@ -103,6 +119,7 @@ struct Entry { project_id: ProjectId, handle: PtyHandle, session: TerminalSession, + managed_temp_handle: Option, _model_server_guard: Option, } @@ -262,6 +279,24 @@ impl TerminalSessions { handle: PtyHandle, session: TerminalSession, model_server_guard: Option, + ) { + self.insert_in_project_with_runtime_resources( + project_id, + handle, + session, + model_server_guard, + None, + ); + } + + /// Inserts a freshly-opened session and retains runtime resources until removal. + pub fn insert_in_project_with_runtime_resources( + &self, + project_id: ProjectId, + handle: PtyHandle, + session: TerminalSession, + model_server_guard: Option, + managed_temp_handle: Option, ) { if let Ok(mut map) = self.entries.lock() { map.insert( @@ -270,6 +305,7 @@ impl TerminalSessions { project_id, handle, session, + managed_temp_handle, _model_server_guard: model_server_guard, }, ); @@ -445,13 +481,20 @@ impl TerminalSessions { /// Removes a session from the registry, returning its handle if present. Also /// drops any conversation binding pointing at it (no stale `session_for`). pub fn remove(&self, id: &SessionId) -> Option { + self.remove_runtime(id).map(|removed| removed.handle) + } + + /// Removes a session and returns all runtime resources retained for it. + pub fn remove_runtime(&self, id: &SessionId) -> Option { if let Ok(mut c) = self.conversations.lock() { c.retain(|_, sid| sid != id); } - self.entries - .lock() - .ok() - .and_then(|mut m| m.remove(id).map(|e| e.handle)) + self.entries.lock().ok().and_then(|mut m| { + m.remove(id).map(|e| RemovedPtySession { + handle: e.handle, + managed_temp_handle: e.managed_temp_handle, + }) + }) } /// Number of currently-registered sessions. @@ -487,6 +530,8 @@ struct StructuredEntry { agent_id: AgentId, /// La cellule (feuille de layout) qui héberge actuellement la vue. node_id: NodeId, + /// Launch-scoped managed-temp handle retained for release. + managed_temp_handle: Option, /// Optional local-model-server usage guard retained for the live session. _model_server_guard: Option, } @@ -579,6 +624,26 @@ impl StructuredSessions { agent_id: AgentId, node_id: NodeId, model_server_guard: Option, + ) { + self.insert_in_project_with_runtime_resources( + project_id, + session, + agent_id, + node_id, + model_server_guard, + None, + ); + } + + /// Enregistre une session structurée et retient ses ressources runtime. + pub fn insert_in_project_with_runtime_resources( + &self, + project_id: ProjectId, + session: Arc, + agent_id: AgentId, + node_id: NodeId, + model_server_guard: Option, + managed_temp_handle: Option, ) { if let Ok(mut map) = self.entries.lock() { let id = session.id(); @@ -589,6 +654,7 @@ impl StructuredSessions { session, agent_id, node_id, + managed_temp_handle, _model_server_guard: model_server_guard, }, ); @@ -831,17 +897,30 @@ impl StructuredSessions { /// Retire une session du registre, retournant la session si présente (pour que /// l'appelant la `shutdown` hors du verrou). pub fn remove(&self, id: &SessionId) -> Option> { + self.remove_runtime(id).map(|removed| removed.session) + } + + /// Retire une session du registre, retournant ses ressources runtime. + pub fn remove_runtime(&self, id: &SessionId) -> Option { self.entries .lock() .ok() - .and_then(|mut m| m.remove(id).map(|e| e.session)) + .and_then(|mut m| { + m.remove(id).map(|e| RemovedStructuredSession { + session: e.session, + managed_temp_handle: e.managed_temp_handle, + }) + }) .or_else(|| { self.ticket_assistants.lock().ok().and_then(|mut m| { let issue_ref = m .iter() .find(|(_, e)| e.session.id() == *id) .map(|(issue_ref, _)| *issue_ref)?; - m.remove(&issue_ref).map(|e| e.session) + m.remove(&issue_ref).map(|e| RemovedStructuredSession { + session: e.session, + managed_temp_handle: None, + }) }) }) } diff --git a/crates/application/src/terminal/usecases.rs b/crates/application/src/terminal/usecases.rs index a66246ab..30b13a59 100644 --- a/crates/application/src/terminal/usecases.rs +++ b/crates/application/src/terminal/usecases.rs @@ -9,6 +9,7 @@ use domain::{ }; use crate::error::AppError; +use crate::managed_temp::{ReleaseManagedTemp, ReleaseManagedTempInput}; use super::registry::{LiveRuntimeState, LiveSessionKind, LiveSessions, TerminalSessions}; @@ -202,13 +203,39 @@ pub struct CloseTerminalOutput { pub struct CloseTerminal { pty: Arc, sessions: Arc, + managed_temp_releaser: Option>, } impl CloseTerminal { /// Builds the use case. #[must_use] pub fn new(pty: Arc, sessions: Arc) -> Self { - Self { pty, sessions } + Self { + pty, + sessions, + managed_temp_releaser: None, + } + } + + /// Injects the releaser for launch-scoped managed temp roots. + #[must_use] + pub fn with_managed_temp_releaser(mut self, releaser: Arc) -> Self { + self.managed_temp_releaser = Some(releaser); + self + } + + /// Releases a launch-scoped managed temp handle retained by a live session. + pub async fn release_managed_temp_handle( + &self, + handle: Option, + ) -> Result<(), AppError> { + let (Some(releaser), Some(handle)) = (&self.managed_temp_releaser, handle) else { + return Ok(()); + }; + releaser + .execute(ReleaseManagedTempInput { handle }) + .await + .map(|_| ()) } /// Kills the session's PTY and removes it from the registry. Idempotent on @@ -226,7 +253,12 @@ impl CloseTerminal { .handle(&input.session_id) .ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?; let status = self.pty.kill(&handle).await?; - self.sessions.remove(&input.session_id); + let managed_temp_handle = self + .sessions + .remove_runtime(&input.session_id) + .and_then(|removed| removed.managed_temp_handle); + self.release_managed_temp_handle(managed_temp_handle) + .await?; Ok(CloseTerminalOutput { code: status.code }) } } @@ -264,11 +296,15 @@ pub async fn stop_live_agent_runtime_in_project( live.mark_runtime_state(snapshot, LiveRuntimeState::Stopping); match snapshot.kind { LiveSessionKind::Structured => { - if let Some(session) = live.structured.remove(&snapshot.session_id) { - session + if let Some(removed) = live.structured.remove_runtime(&snapshot.session_id) { + removed + .session .shutdown() .await .map_err(|e| AppError::Process(e.to_string()))?; + close + .release_managed_temp_handle(removed.managed_temp_handle) + .await?; } } LiveSessionKind::Pty => { diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 2abe2192..52f54a9d 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -53,12 +53,12 @@ use domain::{PtySize, SessionId, SkillId, SkillRef}; use uuid::Uuid; use application::{ - AppError, CloseTerminal, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, - DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, LiveSessions, - ManagedTempExposureRequest, PermissionProjectorRegistry, ReadAgentContext, - ReadAgentContextInput, ResolveManagedTemp, StructuredRoutingMode, StructuredSessions, - TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, - UpdateAgentEffortInput, + AllocateManagedTemp, AppError, CloseTerminal, CreateAgentFromScratch, CreateAgentInput, + DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, + LiveSessions, ManagedTempExposureRequest, PermissionProjectorRegistry, ReadAgentContext, + ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode, + StructuredSessions, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, + UpdateAgentEffort, UpdateAgentEffortInput, }; // --------------------------------------------------------------------------- @@ -726,6 +726,10 @@ impl EventBus for SpyBus { #[derive(Default)] struct FakeManagedTempStore { leases: Mutex>, + allocation_path: Mutex>, + fail_allocate: Mutex, + allocations: Mutex>, + releases: Mutex>, } impl FakeManagedTempStore { @@ -735,17 +739,53 @@ impl FakeManagedTempStore { .unwrap() .insert(lease.handle.clone(), lease); } + + fn set_allocation_path(&self, path: String) { + *self.allocation_path.lock().unwrap() = Some(LocalPath::new(path)); + } + + fn fail_allocate(&self) { + *self.fail_allocate.lock().unwrap() = true; + } + + fn releases(&self) -> Vec { + self.releases.lock().unwrap().clone() + } } #[async_trait] impl ManagedTempStore for FakeManagedTempStore { async fn allocate( &self, - _allocation: ManagedTempAllocation, + allocation: ManagedTempAllocation, ) -> Result { - Err(ManagedTempError::Store( - "allocate is not used by these tests".to_owned(), - )) + if *self.fail_allocate.lock().unwrap() { + return Err(ManagedTempError::Store("allocate failed".to_owned())); + } + let path = self + .allocation_path + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| LocalPath::new(format!("/managed/{}", allocation.handle.as_str()))); + self.allocations.lock().unwrap().push(allocation.clone()); + let lease = ManagedTempLease { + handle: allocation.handle.clone(), + kind: allocation.kind, + owner_project_id: allocation.owner_project_id, + purpose: allocation.purpose, + path, + state: ManagedTempState::Active, + created_at_ms: allocation.created_at_ms, + last_access_at_ms: allocation.created_at_ms, + expires_at_ms: allocation.expires_at_ms, + released_at_ms: None, + }; + self.leases + .lock() + .unwrap() + .insert(lease.handle.clone(), lease.clone()); + Ok(lease) } async fn resolve( @@ -764,12 +804,15 @@ impl ManagedTempStore for FakeManagedTempStore { async fn release( &self, - _handle: &ManagedTempHandle, - _now_ms: u64, + handle: &ManagedTempHandle, + now_ms: u64, ) -> Result { - Err(ManagedTempError::Store( - "release is not used by these tests".to_owned(), - )) + self.releases.lock().unwrap().push(handle.clone()); + let mut leases = self.leases.lock().unwrap(); + let lease = leases.get_mut(handle).ok_or(ManagedTempError::NotFound)?; + lease.state = ManagedTempState::Released; + lease.released_at_ms = Some(now_ms); + Ok(lease.clone()) } async fn gc(&self, _now_ms: u64) -> Result { @@ -4584,6 +4627,246 @@ async fn managed_temp_exposure_enriches_codex_policy_and_os_sandbox_from_same_re let _ = std::fs::remove_dir_all(&root); } +fn managed_temp_usecases( + store: &Arc, +) -> ( + Arc, + Arc, + Arc, +) { + ( + Arc::new(AllocateManagedTemp::new( + Arc::clone(store) as Arc, + Arc::new(SeqIds::new()), + Arc::new(FixedClock), + )), + Arc::new(ResolveManagedTemp::new( + Arc::clone(store) as Arc, + Arc::new(FixedClock), + )), + Arc::new(ReleaseManagedTemp::new( + Arc::clone(store) as Arc, + Arc::new(FixedClock), + )), + ) +} + +fn sandbox_has_rw_root(sandbox: &domain::sandbox::SandboxPlan, root: &str) -> bool { + sandbox + .allowed + .iter() + .any(|grant| grant.abs_root == root && grant.access == domain::sandbox::PathAccess::RW) +} + +#[tokio::test] +async fn launch_pty_allocates_managed_tmp_env_and_sandbox_from_same_path() { + let root = fresh_temp_dir("launch-tmp-pty"); + let tmp = root.join("managed-tmp-main").to_string_lossy().into_owned(); + let store = Arc::new(FakeManagedTempStore::default()); + store.set_allocation_path(tmp.clone()); + let (allocator, _resolver, releaser) = managed_temp_usecases(&store); + let profile = codex_profile().with_projector(ProjectorKey::Codex); + let (launch, agent, _fs, pty, _sessions) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Ask)), + ); + let launch = launch.with_launch_managed_temp(allocator, releaser); + + launch + .execute(launch_input_with_project_root(agent.id, &root)) + .await + .expect("launch"); + + let spawn = pty.spawns().into_iter().next().expect("spawn"); + let env: HashMap<_, _> = spawn.env.into_iter().collect(); + assert_eq!(env.get("TMPDIR"), Some(&tmp)); + assert_eq!(env.get("TMP"), Some(&tmp)); + assert_eq!(env.get("TEMP"), Some(&tmp)); + let sandbox = spawn.sandbox.expect("sandbox plan"); + assert!(sandbox_has_rw_root(&sandbox, &tmp)); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn launch_structured_codex_uses_same_managed_tmp_for_env_sandbox_and_writable_roots() { + let root = fresh_temp_dir("launch-tmp-structured"); + let tmp = root.join("managed-tmp-main").to_string_lossy().into_owned(); + let store = Arc::new(FakeManagedTempStore::default()); + store.set_allocation_path(tmp.clone()); + let (allocator, _resolver, releaser) = managed_temp_usecases(&store); + let profile = codex_profile() + .with_projector(ProjectorKey::Codex) + .with_structured_adapter(StructuredAdapter::Codex); + let (mut launch, agent, _fs, pty, _sessions) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Ask)), + ); + let factory = FakeStructuredFactory::new(trace(), sid(892)); + let structured = Arc::new(StructuredSessions::new()); + launch = launch + .with_launch_managed_temp(allocator, releaser) + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + + launch + .execute(launch_input_with_project_root(agent.id, &root)) + .await + .expect("structured launch"); + + assert!(pty.spawns().is_empty()); + let env = factory.envs().remove(0); + let env: HashMap<_, _> = env.into_iter().collect(); + assert_eq!(env.get("TMPDIR"), Some(&tmp)); + assert_eq!(env.get("TMP"), Some(&tmp)); + assert_eq!(env.get("TEMP"), Some(&tmp)); + let Some(domain::ports::StructuredProviderLaunchPolicy::Codex { writable_roots, .. }) = + factory.policies().into_iter().next().unwrap() + else { + panic!("expected Codex policy"); + }; + assert!(writable_roots.contains(&tmp)); + let sandbox = factory.sandboxes().remove(0).expect("sandbox plan"); + assert!(sandbox_has_rw_root(&sandbox, &tmp)); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn launch_managed_tmp_allocation_failure_fails_closed_before_spawn() { + let store = Arc::new(FakeManagedTempStore::default()); + store.fail_allocate(); + let (allocator, _resolver, releaser) = managed_temp_usecases(&store); + let profile = codex_profile().with_projector(ProjectorKey::Codex); + let (launch, agent, _fs, pty, _sessions) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Ask)), + ); + let launch = launch.with_launch_managed_temp(allocator, releaser); + + let err = launch + .execute(launch_input(agent.id)) + .await + .expect_err("allocation failure must fail launch"); + + assert!(matches!(err, AppError::Store(_))); + assert!(pty.spawns().is_empty(), "no spawn after allocation failure"); +} + +#[tokio::test] +async fn stop_pty_session_releases_primary_launch_managed_tmp() { + let root = fresh_temp_dir("launch-tmp-stop"); + let tmp = root.join("managed-tmp-main").to_string_lossy().into_owned(); + let store = Arc::new(FakeManagedTempStore::default()); + store.set_allocation_path(tmp); + let (allocator, _resolver, releaser) = managed_temp_usecases(&store); + let profile = codex_profile().with_projector(ProjectorKey::Codex); + let (launch, agent, _fs, pty, sessions) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Ask)), + ); + let launch = launch.with_launch_managed_temp(allocator, Arc::clone(&releaser)); + let out = launch + .execute(launch_input_with_project_root(agent.id, &root)) + .await + .expect("launch"); + let close = CloseTerminal::new(Arc::new(pty.clone()), sessions) + .with_managed_temp_releaser(Arc::clone(&releaser)); + + close + .execute(application::CloseTerminalInput { + session_id: out.session.id, + }) + .await + .expect("close"); + + assert_eq!(store.releases().len(), 1, "primary temp handle released"); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn launch_managed_tmp_and_exposure_roots_are_union_without_duplicates() { + let root = fresh_temp_dir("launch-tmp-union"); + let managed_root = root.join("managed-tmp-main").to_string_lossy().into_owned(); + let store = Arc::new(FakeManagedTempStore::default()); + store.set_allocation_path(managed_root.clone()); + let handle = ManagedTempHandle::new("managed-temp-handle-1").unwrap(); + store.insert(ManagedTempLease { + handle: handle.clone(), + kind: ManagedTempKind::Directory, + owner_project_id: None, + purpose: "test".to_owned(), + path: LocalPath::new(managed_root.clone()), + state: ManagedTempState::Active, + created_at_ms: 1, + last_access_at_ms: 1, + expires_at_ms: Some(10_000), + released_at_ms: None, + }); + let (allocator, resolver, releaser) = managed_temp_usecases(&store); + let profile = codex_profile() + .with_projector(ProjectorKey::Codex) + .with_structured_adapter(StructuredAdapter::Codex); + let (mut launch, agent, _fs, pty, _sessions) = launch_with_projection( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + Some(full_registry()), + Some(perm_doc(Posture::Ask)), + ); + let factory = FakeStructuredFactory::new(trace(), sid(893)); + let structured = Arc::new(StructuredSessions::new()); + launch = launch + .with_launch_managed_temp(allocator, releaser) + .with_managed_temp_resolver(resolver) + .with_structured_routing_mode(StructuredRoutingMode::RequireStructured) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + let mut input = launch_input_with_project_root(agent.id, &root); + input.managed_temp_exposure = ManagedTempExposureRequest { + handles: vec![handle.as_str().to_owned(), handle.as_str().to_owned()], + }; + + launch.execute(input).await.expect("structured launch"); + + assert!(pty.spawns().is_empty()); + let Some(domain::ports::StructuredProviderLaunchPolicy::Codex { writable_roots, .. }) = + factory.policies().into_iter().next().unwrap() + else { + panic!("expected Codex policy"); + }; + assert_eq!( + writable_roots, + vec![root.to_string_lossy().into_owned(), managed_root.clone()], + "main launch temp and additive exposure path must be deduped" + ); + let sandbox = factory.sandboxes().remove(0).expect("sandbox plan"); + assert_eq!( + sandbox + .allowed + .iter() + .filter(|grant| grant.abs_root == managed_root) + .count(), + 1, + "OS sandbox must also dedupe the shared path" + ); + let _ = std::fs::remove_dir_all(&root); +} + #[tokio::test] async fn managed_temp_exposure_fails_closed_without_resolver_before_spawn() { let profile = codex_profile().with_projector(ProjectorKey::Codex); diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 32f9c9e1..1d46a293 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -1626,11 +1626,6 @@ impl BackendCore { Arc::clone(&pty_port), Arc::clone(&terminal_sessions), )); - let close_terminal = Arc::new(CloseTerminal::new( - Arc::clone(&pty_port), - Arc::clone(&terminal_sessions), - )); - // --- Layout use cases (L4 + #4) --- let load_layout = Arc::new(LoadLayout::new( Arc::clone(&store_port), @@ -1884,6 +1879,10 @@ impl BackendCore { Arc::clone(&managed_temp_store_port), Arc::clone(&clock) as Arc, )); + let close_terminal = Arc::new( + CloseTerminal::new(Arc::clone(&pty_port), Arc::clone(&terminal_sessions)) + .with_managed_temp_releaser(Arc::clone(&release_managed_temp)), + ); let gc_managed_temp = Arc::new(GcManagedTemp::new( Arc::clone(&managed_temp_store_port), Arc::clone(&clock) as Arc, @@ -2243,6 +2242,10 @@ impl BackendCore { .with_local_model_server(Arc::clone(&ensure_local_model_server)) .with_secret_store(Arc::clone(&secret_store_port)) .with_managed_temp_resolver(Arc::clone(&resolve_managed_temp)) + .with_launch_managed_temp( + Arc::clone(&allocate_managed_temp), + Arc::clone(&release_managed_temp), + ) .with_structured( Arc::clone(&session_factory), Arc::clone(&structured_sessions), @@ -2282,6 +2285,10 @@ impl BackendCore { .with_local_model_server(Arc::clone(&ensure_local_model_server)) .with_secret_store(Arc::clone(&secret_store_port)) .with_managed_temp_resolver(Arc::clone(&resolve_managed_temp)) + .with_launch_managed_temp( + Arc::clone(&allocate_managed_temp), + Arc::clone(&release_managed_temp), + ) .with_structured( Arc::clone(&session_factory), Arc::clone(&structured_sessions),