feat(infra): temp IdeA launch-scoped alloué par lancement d'agent, injecté via TMPDIR/TMP/TEMP, writable_roots + release au stop, fail-closed — #200 (QA verte)
This commit is contained in:
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<Item = LocalPath>) -> Vec<String> {
|
||||
dedupe_string_paths(paths.into_iter().map(|path| path.as_str().to_owned()))
|
||||
}
|
||||
|
||||
fn dedupe_string_paths(paths: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
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<Arc<ResolveManagedTemp>>,
|
||||
/// Allocates the primary launch-scoped managed temp directory.
|
||||
managed_temp_allocator: Option<Arc<AllocateManagedTemp>>,
|
||||
/// Releases the primary launch-scoped managed temp directory when launch fails
|
||||
/// before registration.
|
||||
managed_temp_releaser: Option<Arc<ReleaseManagedTemp>>,
|
||||
/// 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<AllocateManagedTemp>,
|
||||
releaser: Arc<ReleaseManagedTemp>,
|
||||
) -> 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<Option<LaunchManagedTemp>, 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<LaunchManagedTemp>,
|
||||
model_server_guard: Option<ModelServerUseGuard>,
|
||||
) -> Result<LaunchAgentOutput, AppError> {
|
||||
// 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) ──
|
||||
|
||||
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<dyn AgentSession>,
|
||||
/// Launch-scoped managed-temp handle retained for release.
|
||||
pub managed_temp_handle: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
_model_server_guard: Option<ModelServerUseGuard>,
|
||||
}
|
||||
|
||||
@ -262,6 +279,24 @@ impl TerminalSessions {
|
||||
handle: PtyHandle,
|
||||
session: TerminalSession,
|
||||
model_server_guard: Option<ModelServerUseGuard>,
|
||||
) {
|
||||
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<ModelServerUseGuard>,
|
||||
managed_temp_handle: Option<String>,
|
||||
) {
|
||||
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<PtyHandle> {
|
||||
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<RemovedPtySession> {
|
||||
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<String>,
|
||||
/// Optional local-model-server usage guard retained for the live session.
|
||||
_model_server_guard: Option<ModelServerUseGuard>,
|
||||
}
|
||||
@ -579,6 +624,26 @@ impl StructuredSessions {
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
model_server_guard: Option<ModelServerUseGuard>,
|
||||
) {
|
||||
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<dyn AgentSession>,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
model_server_guard: Option<ModelServerUseGuard>,
|
||||
managed_temp_handle: Option<String>,
|
||||
) {
|
||||
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<Arc<dyn AgentSession>> {
|
||||
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<RemovedStructuredSession> {
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -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<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
managed_temp_releaser: Option<Arc<ReleaseManagedTemp>>,
|
||||
}
|
||||
|
||||
impl CloseTerminal {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> 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<ReleaseManagedTemp>) -> 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<String>,
|
||||
) -> 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 => {
|
||||
|
||||
@ -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<HashMap<ManagedTempHandle, ManagedTempLease>>,
|
||||
allocation_path: Mutex<Option<LocalPath>>,
|
||||
fail_allocate: Mutex<bool>,
|
||||
allocations: Mutex<Vec<ManagedTempAllocation>>,
|
||||
releases: Mutex<Vec<ManagedTempHandle>>,
|
||||
}
|
||||
|
||||
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<ManagedTempHandle> {
|
||||
self.releases.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedTempStore for FakeManagedTempStore {
|
||||
async fn allocate(
|
||||
&self,
|
||||
_allocation: ManagedTempAllocation,
|
||||
allocation: ManagedTempAllocation,
|
||||
) -> Result<ManagedTempLease, ManagedTempError> {
|
||||
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<ManagedTempLease, ManagedTempError> {
|
||||
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<ManagedTempGcReport, ManagedTempError> {
|
||||
@ -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<FakeManagedTempStore>,
|
||||
) -> (
|
||||
Arc<AllocateManagedTemp>,
|
||||
Arc<ResolveManagedTemp>,
|
||||
Arc<ReleaseManagedTemp>,
|
||||
) {
|
||||
(
|
||||
Arc::new(AllocateManagedTemp::new(
|
||||
Arc::clone(store) as Arc<dyn ManagedTempStore>,
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
Arc::new(ResolveManagedTemp::new(
|
||||
Arc::clone(store) as Arc<dyn ManagedTempStore>,
|
||||
Arc::new(FixedClock),
|
||||
)),
|
||||
Arc::new(ReleaseManagedTemp::new(
|
||||
Arc::clone(store) as Arc<dyn ManagedTempStore>,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user