feat(infra): tmp partagé inter-projets géré par IdeA (handles opaques, leases, GC, projection sandbox) — #189 (QA verte)

- Domaine: port ManagedTempStore + modèle (handle opaque, kind Directory/File, état Active/Released, allocation/lease, expiry).
- Application: use cases Allocate/Resolve/Release/GcManagedTemp, horodatés via Clock.
- Infrastructure: impl FS FsManagedTempStore sous app_data_dir/managed-tmp (root global inter-projets, métadonnées par handle, GC des leases expirés/libérés).
- Exposition MCP contrôlée (ManagedTempToolProvider, catalogue) + câblage sandbox: les agents résolvent un handle au lieu d'inventer un path libre.
- Tests: application/infrastructure managed_temp.
This commit is contained in:
2026-08-13 13:34:33 +02:00
parent d7dfbcdfcb
commit fdca55ecd4
22 changed files with 2000 additions and 72 deletions

View File

@ -29,6 +29,7 @@ pub mod inspector;
pub mod issues;
pub mod lock;
pub mod mailbox;
pub mod managed_temp;
pub mod model_catalogue;
pub mod model_server;
pub mod orchestrator;
@ -75,6 +76,7 @@ pub use inspector::{
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
pub use lock::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLockError};
pub use mailbox::InMemoryMailbox;
pub use managed_temp::FsManagedTempStore;
pub use model_catalogue::{
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
};
@ -83,8 +85,9 @@ pub use model_server::{
LocalManagedProcess,
};
pub use orchestrator::mcp::{
McpServer, MemoryTransport, StdioTransport, TemplateToolError, TemplateToolProvider,
TicketToolError, TicketToolProvider, ToolPolicyRegistry,
ManagedTempToolError, ManagedTempToolProvider, McpServer, MemoryTransport, StdioTransport,
TemplateToolError, TemplateToolProvider, TicketToolError, TicketToolProvider,
ToolPolicyRegistry,
};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

View File

@ -0,0 +1,241 @@
//! Filesystem-backed managed temporary store.
//!
//! Allocations live under `<app_data_dir>/managed-tmp/<opaque-handle>/`.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ports::{LocalPath, ManagedTempStore};
use domain::{
ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle,
ManagedTempKind, ManagedTempLease, ManagedTempState, ProjectId,
};
const MANAGED_TMP_DIR: &str = "managed-tmp";
const METADATA_FILE: &str = "metadata.json";
const DATA_NAME: &str = "data";
/// Filesystem implementation for global IdeA-managed temporary allocations.
#[derive(Debug, Clone)]
pub struct FsManagedTempStore {
root: PathBuf,
}
impl FsManagedTempStore {
/// Builds the store under `<app_data_dir>/managed-tmp`.
#[must_use]
pub fn new(app_data_dir: impl Into<PathBuf>) -> Self {
Self {
root: app_data_dir.into().join(MANAGED_TMP_DIR),
}
}
/// Returns the global managed temp root.
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredLease {
handle: ManagedTempHandle,
kind: ManagedTempKind,
owner_project_id: Option<ProjectId>,
purpose: String,
state: ManagedTempState,
created_at_ms: u64,
last_access_at_ms: u64,
expires_at_ms: Option<u64>,
released_at_ms: Option<u64>,
}
impl StoredLease {
fn from_allocation(allocation: ManagedTempAllocation) -> Self {
Self {
handle: allocation.handle,
kind: allocation.kind,
owner_project_id: allocation.owner_project_id,
purpose: allocation.purpose,
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,
}
}
fn is_expired(&self, now_ms: u64) -> bool {
self.expires_at_ms.is_some_and(|expires| expires <= now_ms)
}
fn to_lease(&self, path: PathBuf) -> ManagedTempLease {
ManagedTempLease {
handle: self.handle.clone(),
kind: self.kind,
owner_project_id: self.owner_project_id,
purpose: self.purpose.clone(),
path: LocalPath::new(path.to_string_lossy().into_owned()),
state: self.state,
created_at_ms: self.created_at_ms,
last_access_at_ms: self.last_access_at_ms,
expires_at_ms: self.expires_at_ms,
released_at_ms: self.released_at_ms,
}
}
}
fn io_error(err: std::io::Error) -> ManagedTempError {
ManagedTempError::Store(err.to_string())
}
fn serde_error(err: serde_json::Error) -> ManagedTempError {
ManagedTempError::Store(err.to_string())
}
fn allocation_dir(root: &Path, handle: &ManagedTempHandle) -> PathBuf {
root.join(handle.as_str())
}
fn metadata_path(root: &Path, handle: &ManagedTempHandle) -> PathBuf {
allocation_dir(root, handle).join(METADATA_FILE)
}
fn data_path(root: &Path, handle: &ManagedTempHandle) -> PathBuf {
allocation_dir(root, handle).join(DATA_NAME)
}
fn ensure_under_root(root: &Path, candidate: &Path) -> Result<(), ManagedTempError> {
if candidate.starts_with(root) {
Ok(())
} else {
Err(ManagedTempError::Invalid(
"managed temp path escaped root".to_owned(),
))
}
}
async fn read_stored(
root: &Path,
handle: &ManagedTempHandle,
) -> Result<StoredLease, ManagedTempError> {
let path = metadata_path(root, handle);
let bytes = tokio::fs::read(&path).await.map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
ManagedTempError::NotFound
} else {
io_error(err)
}
})?;
let stored: StoredLease = serde_json::from_slice(&bytes).map_err(serde_error)?;
if stored.handle != *handle {
return Err(ManagedTempError::Invalid(
"managed temp metadata handle mismatch".to_owned(),
));
}
Ok(stored)
}
async fn write_stored(root: &Path, stored: &StoredLease) -> Result<(), ManagedTempError> {
let path = metadata_path(root, &stored.handle);
let bytes = serde_json::to_vec_pretty(stored).map_err(serde_error)?;
tokio::fs::write(path, bytes).await.map_err(io_error)
}
#[async_trait]
impl ManagedTempStore for FsManagedTempStore {
async fn allocate(
&self,
allocation: ManagedTempAllocation,
) -> Result<ManagedTempLease, ManagedTempError> {
let handle = allocation.handle.clone();
let dir = allocation_dir(&self.root, &handle);
let data = data_path(&self.root, &handle);
ensure_under_root(&self.root, &dir)?;
ensure_under_root(&self.root, &data)?;
tokio::fs::create_dir_all(&self.root)
.await
.map_err(io_error)?;
tokio::fs::create_dir(&dir).await.map_err(io_error)?;
match allocation.kind {
ManagedTempKind::Directory => tokio::fs::create_dir(&data).await.map_err(io_error)?,
ManagedTempKind::File => {
tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&data)
.await
.map_err(io_error)?;
}
}
let stored = StoredLease::from_allocation(allocation);
write_stored(&self.root, &stored).await?;
Ok(stored.to_lease(data))
}
async fn resolve(
&self,
handle: &ManagedTempHandle,
now_ms: u64,
) -> Result<ManagedTempLease, ManagedTempError> {
let mut stored = read_stored(&self.root, handle).await?;
if stored.state != ManagedTempState::Active || stored.is_expired(now_ms) {
return Err(ManagedTempError::NotFound);
}
let data = data_path(&self.root, handle);
ensure_under_root(&self.root, &data)?;
if tokio::fs::metadata(&data).await.is_err() {
return Err(ManagedTempError::NotFound);
}
stored.last_access_at_ms = now_ms;
write_stored(&self.root, &stored).await?;
Ok(stored.to_lease(data))
}
async fn release(
&self,
handle: &ManagedTempHandle,
now_ms: u64,
) -> Result<ManagedTempLease, ManagedTempError> {
let mut stored = read_stored(&self.root, handle).await?;
stored.state = ManagedTempState::Released;
stored.released_at_ms = Some(now_ms);
stored.last_access_at_ms = now_ms;
write_stored(&self.root, &stored).await?;
Ok(stored.to_lease(data_path(&self.root, handle)))
}
async fn gc(&self, now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError> {
let mut deleted = 0usize;
let mut entries = match tokio::fs::read_dir(&self.root).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(ManagedTempGcReport { deleted: 0 })
}
Err(err) => return Err(io_error(err)),
};
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
let file_type = entry.file_type().await.map_err(io_error)?;
if !file_type.is_dir() {
continue;
}
let raw_handle = entry.file_name().to_string_lossy().into_owned();
let Ok(handle) = ManagedTempHandle::new(raw_handle) else {
continue;
};
let Ok(stored) = read_stored(&self.root, &handle).await else {
continue;
};
if stored.state == ManagedTempState::Released || stored.is_expired(now_ms) {
tokio::fs::remove_dir_all(entry.path())
.await
.map_err(io_error)?;
deleted += 1;
}
}
Ok(ManagedTempGcReport { deleted })
}
}

View File

@ -0,0 +1,122 @@
//! Public MCP managed temporary storage tools.
//!
//! The MCP surface exposes IdeA-managed temporary allocations without accepting
//! caller-supplied filesystem paths. A driving adapter injects the provider that
//! maps these calls to the application use cases.
use async_trait::async_trait;
use domain::Project;
use serde_json::{json, Value};
use super::tools::ToolDef;
/// Error returned by an MCP managed-temp tool provider.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{code}: {message}")]
pub struct ManagedTempToolError {
/// Stable machine-readable code.
pub code: &'static str,
/// Human-readable message.
pub message: String,
}
impl ManagedTempToolError {
/// Builds a typed managed-temp tool error.
#[must_use]
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// Serialises the error as a camelCase JSON object.
#[must_use]
pub fn to_value(&self) -> Value {
json!({ "code": self.code, "message": self.message })
}
}
/// Provider injected by the composition root to execute public managed-temp tools.
#[async_trait]
pub trait ManagedTempToolProvider: Send + Sync {
/// Executes one public managed-temp tool for `project`.
async fn handle_managed_temp_tool(
&self,
project: &Project,
requester: &str,
name: &str,
arguments: Value,
) -> Result<Value, ManagedTempToolError>;
}
/// Returns true when `name` is one of the public managed-temp tools.
#[must_use]
pub fn is_managed_temp_tool(name: &str) -> bool {
matches!(
name,
"idea_managed_temp_allocate"
| "idea_managed_temp_resolve"
| "idea_managed_temp_release"
| "idea_managed_temp_gc"
)
}
/// Public managed-temp tool definitions advertised by `tools/list`.
#[must_use]
pub fn catalogue() -> Vec<ToolDef> {
let handle = json!({
"type": "string",
"pattern": "^[A-Za-z0-9_-]{1,128}$",
"description": "Opaque managed temporary handle returned by allocate."
});
vec![
ToolDef {
name: "idea_managed_temp_allocate",
description: "Allocate an IdeA-managed temporary file or directory and return its \
opaque handle plus metadata. The caller cannot provide a filesystem \
path.",
input_schema: json!({
"type": "object",
"properties": {
"kind": { "type": "string", "enum": ["directory", "file"] },
"purpose": { "type": "string", "description": "Short human/debug purpose." },
"ttlMs": { "type": "integer", "minimum": 1, "description": "Optional lease TTL in milliseconds." }
},
"required": ["kind", "purpose"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_managed_temp_resolve",
description: "Resolve an active IdeA-managed temporary handle. Returns the \
IdeA-controlled local path for the allocation data.",
input_schema: json!({
"type": "object",
"properties": { "handle": handle.clone() },
"required": ["handle"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_managed_temp_release",
description: "Release an IdeA-managed temporary handle, making it eligible for GC.",
input_schema: json!({
"type": "object",
"properties": { "handle": handle },
"required": ["handle"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_managed_temp_gc",
description: "Garbage-collect released or expired IdeA-managed temporary allocations.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
]
}

View File

@ -29,6 +29,7 @@
//! child process).
pub mod jsonrpc;
pub mod managed_temp;
pub mod policy;
pub mod server;
pub mod templates;
@ -39,6 +40,7 @@ pub mod transport;
pub use jsonrpc::{
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
};
pub use managed_temp::{ManagedTempToolError, ManagedTempToolProvider};
pub use policy::ToolPolicyRegistry;
pub use server::McpServer;
pub use templates::{TemplateToolError, TemplateToolProvider};

View File

@ -34,6 +34,7 @@ use super::jsonrpc::{
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
JSONRPC_VERSION,
};
use super::managed_temp::ManagedTempToolProvider;
use super::policy::ToolPolicyRegistry;
use super::templates::TemplateToolProvider;
use super::tickets::TicketToolProvider;
@ -80,6 +81,9 @@ pub struct McpServer {
/// Optional public template provider. The MCP surface says `template`; the
/// provider maps those calls to application/domain `Template` use cases.
template_tools: Option<Arc<dyn TemplateToolProvider>>,
/// Optional public managed-temp provider. The MCP surface says `managed_temp`;
/// the provider maps those calls to application managed-temp use cases.
managed_temp_tools: Option<Arc<dyn ManagedTempToolProvider>>,
/// Optional per-requester MCP tool policy registry for constrained sessions.
tool_policies: Option<Arc<ToolPolicyRegistry>>,
/// Optional durable MCP tool permission store. When wired, absence of project or
@ -101,6 +105,7 @@ impl McpServer {
ready_sink: None,
ticket_tools: None,
template_tools: None,
managed_temp_tools: None,
tool_policies: None,
mcp_tool_permissions: None,
}
@ -141,6 +146,16 @@ impl McpServer {
self
}
/// Attaches the public managed-temp tool provider.
#[must_use]
pub fn with_managed_temp_tools(
mut self,
managed_temp_tools: Arc<dyn ManagedTempToolProvider>,
) -> Self {
self.managed_temp_tools = Some(managed_temp_tools);
self
}
/// Attaches the requester-scoped MCP tool policy registry.
#[must_use]
pub fn with_tool_policies(mut self, tool_policies: Arc<ToolPolicyRegistry>) -> Self {
@ -176,6 +191,7 @@ impl McpServer {
ready_sink: self.ready_sink.clone(),
ticket_tools: self.ticket_tools.clone(),
template_tools: self.template_tools.clone(),
managed_temp_tools: self.managed_temp_tools.clone(),
tool_policies: self.tool_policies.clone(),
mcp_tool_permissions: self.mcp_tool_permissions.clone(),
}
@ -543,6 +559,44 @@ impl McpServer {
};
}
if tools::is_managed_temp_tool(&name) {
let result = match &self.managed_temp_tools {
Some(provider) => {
provider
.handle_managed_temp_tool(&self.project, &self.requester, &name, arguments)
.await
}
None => Err(super::managed_temp::ManagedTempToolError::new(
"notConfigured",
"managed temp tools are not configured",
)),
};
self.publish_processed(&name, result.is_ok());
return match result {
Ok(value) => {
let text = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_owned());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=true is_error=false result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, false))
}
Err(err) => {
let text =
serde_json::to_string(&err.to_value()).unwrap_or_else(|_| err.to_string());
application::diag!(
"[mcp] tools_call end tool={name} requester={requester_label} \
target={arg_target} ok=false is_error=true result_len={} elapsed_ms={}",
text.len(),
started.elapsed().as_millis(),
);
Ok(tool_result_text(&text, true))
}
};
}
// The handshake-provided requester is still passed to the mapper for tools that
// need peer identity.
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {

View File

@ -57,6 +57,7 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_managed_temp_resolve",
];
/// Canonical write/action MCP tools denied by default.
@ -87,6 +88,9 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_template_create",
"idea_template_update",
"idea_template_delete",
"idea_managed_temp_allocate",
"idea_managed_temp_release",
"idea_managed_temp_gc",
];
/// All MCP tool names that have an explicit access classification.
@ -142,6 +146,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_workstate_read"
) || is_ticket_tool(tool)
|| is_template_tool(tool)
|| is_managed_temp_tool(tool)
}
/// Whether `tool` is a public ticket MCP tool.
@ -156,6 +161,12 @@ pub fn is_template_tool(tool: &str) -> bool {
super::templates::is_template_tool(tool)
}
/// Whether `tool` is a public managed-temp MCP tool.
#[must_use]
pub fn is_managed_temp_tool(tool: &str) -> bool {
super::managed_temp::is_managed_temp_tool(tool)
}
/// The full catalogue advertised on `tools/list`.
///
/// Exactly the tools whose mapping target already exists as an
@ -415,6 +426,7 @@ pub fn catalogue() -> Vec<ToolDef> {
];
tools.extend(super::tickets::catalogue());
tools.extend(super::templates::catalogue());
tools.extend(super::managed_temp::catalogue());
tools
}
@ -1090,4 +1102,37 @@ mod tests {
"status enum must match WorkStatus labels exactly"
);
}
#[test]
fn catalogue_advertises_managed_temp_tools_with_expected_access() {
let cat = catalogue();
for tool in [
"idea_managed_temp_allocate",
"idea_managed_temp_resolve",
"idea_managed_temp_release",
"idea_managed_temp_gc",
] {
assert!(
cat.iter().any(|entry| entry.name == tool),
"catalogue must contain {tool}"
);
assert!(tool_returns_reply(tool), "{tool} returns JSON inline");
}
assert_eq!(
tool_access("idea_managed_temp_resolve"),
Some(McpToolAccess::Read)
);
assert_eq!(
tool_access("idea_managed_temp_allocate"),
Some(McpToolAccess::WriteAction)
);
assert_eq!(
tool_access("idea_managed_temp_release"),
Some(McpToolAccess::WriteAction)
);
assert_eq!(
tool_access("idea_managed_temp_gc"),
Some(McpToolAccess::WriteAction)
);
}
}