feat(infra): injection sandbox des handles managed temp résolus dans les sessions agents — #190

This commit is contained in:
2026-08-13 14:08:36 +02:00
parent 1b7ff56419
commit 4e9b205b40
9 changed files with 335 additions and 33 deletions

View File

@ -29,11 +29,12 @@ use domain::permission::{
ProjectionContext, ProjectorKey,
};
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, Clock,
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, OutputStream, PermissionStore,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyStream,
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, SystemPermissionStore,
IdGenerator, LocalPath, ManagedTempStore, MemoryError, MemoryQuery, MemoryRecall, OutputStream,
PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
SystemPermissionStore,
};
use domain::profile::{
AgentProfile, ContextInjection, EffortSelection, McpCapability, McpConfigStrategy,
@ -42,17 +43,22 @@ use domain::profile::{
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{
ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle,
ManagedTempKind, ManagedTempLease, ManagedTempState,
};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType, NetworkPolicy};
use domain::{PermissionSet, ProjectPermissions, ProjectSystemPermissions, SystemPermissionSet};
use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput,
LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, LiveSessions,
PermissionProjectorRegistry, ReadAgentContext, ReadAgentContextInput, StructuredRoutingMode,
StructuredSessions, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
UpdateAgentEffort, UpdateAgentEffortInput,
AppError, CloseTerminal, CreateAgentFromScratch, CreateAgentInput, DeleteAgent,
DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, LiveSessions,
ManagedTempExposureRequest, PermissionProjectorRegistry, ReadAgentContext,
ReadAgentContextInput, ResolveManagedTemp, StructuredRoutingMode, StructuredSessions,
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
UpdateAgentEffortInput,
};
// ---------------------------------------------------------------------------
@ -717,6 +723,68 @@ impl EventBus for SpyBus {
}
}
#[derive(Default)]
struct FakeManagedTempStore {
leases: Mutex<HashMap<ManagedTempHandle, ManagedTempLease>>,
}
impl FakeManagedTempStore {
fn insert(&self, lease: ManagedTempLease) {
self.leases
.lock()
.unwrap()
.insert(lease.handle.clone(), lease);
}
}
#[async_trait]
impl ManagedTempStore for FakeManagedTempStore {
async fn allocate(
&self,
_allocation: ManagedTempAllocation,
) -> Result<ManagedTempLease, ManagedTempError> {
Err(ManagedTempError::Store(
"allocate is not used by these tests".to_owned(),
))
}
async fn resolve(
&self,
handle: &ManagedTempHandle,
_now_ms: u64,
) -> Result<ManagedTempLease, ManagedTempError> {
self.leases
.lock()
.unwrap()
.get(handle)
.filter(|lease| lease.state == ManagedTempState::Active)
.cloned()
.ok_or(ManagedTempError::NotFound)
}
async fn release(
&self,
_handle: &ManagedTempHandle,
_now_ms: u64,
) -> Result<ManagedTempLease, ManagedTempError> {
Err(ManagedTempError::Store(
"release is not used by these tests".to_owned(),
))
}
async fn gc(&self, _now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError> {
Ok(ManagedTempGcReport { deleted: 0 })
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
1_000
}
}
struct SeqIds(Mutex<u128>);
impl SeqIds {
fn new() -> Self {
@ -1220,6 +1288,7 @@ fn launch_input_with_project(agent_id: AgentId, project: Project) -> LaunchAgent
allow_structured_alongside_pty: false,
require_structured: false,
fresh_start: false,
managed_temp_exposure: ManagedTempExposureRequest::default(),
}
}
@ -4242,6 +4311,107 @@ async fn codex_git_enrichment_canonicalizes_and_avoids_parent_grants() {
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn managed_temp_exposure_enriches_codex_policy_and_os_sandbox_from_same_resolution() {
let root = fresh_temp_dir("managed-temp-exposure");
let managed_root = root.join("tmp").join("agent-output");
std::fs::create_dir_all(&managed_root).unwrap();
let managed_root = managed_root.to_string_lossy().into_owned();
let handle = ManagedTempHandle::new("managed-temp-handle-1").unwrap();
let store = Arc::new(FakeManagedTempStore::default());
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 profile = codex_profile()
.with_projector(ProjectorKey::Codex)
.with_structured_adapter(StructuredAdapter::Codex);
let (mut launch, agent, _fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
Some(perm_doc(Posture::Ask)),
);
let resolver = Arc::new(ResolveManagedTemp::new(
Arc::clone(&store) as Arc<dyn ManagedTempStore>,
Arc::new(FixedClock),
));
let factory = FakeStructuredFactory::new(trace(), sid(891));
let structured = Arc::new(StructuredSessions::new());
launch = launch
.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(),
"structured route must not spawn PTY"
);
assert_eq!(
factory.policies(),
vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
sandbox_mode: "workspace-write".to_owned(),
writable_roots: vec![managed_root.clone()],
network_access: false,
})],
"Codex writable_roots must come from the resolved managed-temp path"
);
let sandbox = factory.sandboxes().remove(0).expect("sandbox plan");
assert_eq!(
sandbox.allowed,
vec![domain::sandbox::PathGrant {
abs_root: managed_root,
access: domain::sandbox::PathAccess::RW,
}],
"OS sandbox grants must match the Codex writable roots"
);
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);
let (launch, agent, _fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
target: "AGENTS.md".to_owned(),
}),
Some(full_registry()),
Some(perm_doc(Posture::Ask)),
);
let mut input = launch_input(agent.id);
input.managed_temp_exposure = ManagedTempExposureRequest {
handles: vec!["managed-temp-handle-1".to_owned()],
};
let err = launch.execute(input).await.expect_err("must fail closed");
assert!(
matches!(err, AppError::Invalid(message) if message.contains("resolver is not configured"))
);
assert!(
pty.spawns().is_empty(),
"managed-temp exposure failure must happen before spawning the agent"
);
}
/// (1d) A non-projectable profile (no projector, no CLAUDE.md, no Codex signal) ⇒
/// no projection at all, even with a full registry and a posed policy.
#[tokio::test]