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:
2026-08-14 23:55:54 +02:00
parent 1d10a6ee80
commit 1157de1dda
5 changed files with 558 additions and 45 deletions

View File

@ -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);