feat(permissions): voie projection CLI (LP0→LP3) + checkpoint Codex/input

Jalon vert regroupant deux chantiers entrelacés dans le working tree,
indissociables au niveau fichier mais tous deux verts (cargo test
--workspace + tests frontend permissions au vert).

Permissions — voie « projection CLI » (advisory), complète :
- LP0 domaine pur : modèle PermissionSet/EffectivePermissions, resolve
  deny-wins + postures Allow<Ask<Deny (crates/domain/src/permission.rs).
- LP1 store : FsPermissionStore (.ideai/permissions.json).
- LP2 use cases : Get/Update project, Update agent override, Resolve.
- LP3 projecteurs Claude/Codex (settings.local.json / config.toml),
  câblage launch-path + PermissionProjectorRegistry, nettoyage des
  fichiers Replace orphelins au swap de profil (LP3-4), composition root
  + commandes Tauri, UI PermissionsPanel (projet + override agent).
- ports.rs : PermissionStore + FileSystem::remove_file (cleanup au swap).

Reste ouvert (hors scope, marqué dans le code) : LP4 enforcement OS
airtight (Landlock fichiers) + résumé de permissions injecté.

Inclut aussi le chantier Codex/input/sessions structurées en cours
(McpConfigStrategy, StructuredAdapter, gestion d'input) partageant les
mêmes fichiers (lifecycle.rs, commands.rs, dto.rs, state.rs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:39:18 +02:00
parent 46492506e1
commit 27597eb64e
41 changed files with 6513 additions and 269 deletions

View File

@ -18,10 +18,12 @@
//! duplicated here.
use std::sync::Arc;
use std::time::Duration;
use application::OrchestratorService;
use domain::{DomainEvent, OrchestrationSource, Project};
use serde_json::{json, Value};
use tokio::sync::mpsc;
use super::jsonrpc::{
error_codes, JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError,
@ -32,6 +34,18 @@ use super::tools::{self, ToolMapError};
/// The MCP protocol version this server speaks (advertised on `initialize`).
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// **Server-side safety net** bounding the synchronous `idea_ask_agent` rendezvous.
///
/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another
/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application
/// layer). The application layer already bounds the rendezvous itself, but this
/// adapter adds its own **finite** outer bound so a wedged target can never park a
/// `tools/call` task forever: on expiry the call returns a clean JSON-RPC error
/// instead of hanging. Generous on purpose (delegated turns can be very long), but
/// never infinite. Every other tool (`idea_reply`, `idea_list_agents`, …) is left
/// untouched — they don't rendezvous.
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
///
/// Cheap to clone the dependencies it holds; one instance serves one project's
@ -53,6 +67,18 @@ pub struct McpServer {
/// frozen `"mcp"` placeholder; empty ⇒ the legacy `"mcp"` label (back-compat for
/// M2 callers and connections that arrive without a requester).
requester: String,
/// Sink optionnel de **readiness de démarrage** : appelé avec le `requester` brut
/// (handshake) la première fois que le peer émet `initialize` (son CLI est up et
/// parle MCP). La composition root y branche
/// `OrchestratorService::release_agent_cold_start` pour livrer un 1er tour différé
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
/// composition root parse le `requester`. `None` ⇒ no-op.
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
/// Finite outer bound applied **only** to the `idea_ask_agent` rendezvous (see
/// [`ASK_RENDEZVOUS_TIMEOUT`]). Carried per instance so tests can shrink it to a
/// few milliseconds without polluting the public API; production code always uses
/// the default.
ask_rendezvous_timeout: Duration,
}
impl McpServer {
@ -66,6 +92,8 @@ impl McpServer {
project,
events: None,
requester: String::new(),
ready_sink: None,
ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT,
}
}
@ -79,6 +107,17 @@ impl McpServer {
self
}
/// Attache un sink de **readiness de démarrage** : appelé avec l'id (handshake
/// `requester`) de l'agent connecté la première fois qu'il émet `initialize` (son CLI
/// est up et parle MCP). La composition root y branche
/// `OrchestratorService::release_agent_cold_start` pour livrer un éventuel 1er tour
/// différé (fix race cold-launch, signal MCP). Additif : sans sink, no-op.
#[must_use]
pub fn with_ready_sink(mut self, ready_sink: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
self.ready_sink = Some(ready_sink);
self
}
/// Returns a per-connection clone of this server tagged with the connected
/// peer's `requester` id (the loopback handshake's `requester`, cadrage v5 §1.4).
///
@ -94,9 +133,22 @@ impl McpServer {
project: self.project.clone(),
events: self.events.clone(),
requester: requester.into(),
ready_sink: self.ready_sink.clone(),
ask_rendezvous_timeout: self.ask_rendezvous_timeout,
}
}
/// **Test seam** (doc-hidden): shrinks the `idea_ask_agent` rendezvous bound
/// (see [`ASK_RENDEZVOUS_TIMEOUT`]) so a wedged-target test need not wait the
/// full production timeout. Doc-hidden so it does not widen the documented public
/// surface; production code never calls it.
#[doc(hidden)]
#[must_use]
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
self.ask_rendezvous_timeout = timeout;
self
}
/// Serves JSON-RPC messages from `transport`, tagging every processed
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
///
@ -113,19 +165,76 @@ impl McpServer {
/// Every inbound line is handled in isolation: a malformed line or an unknown
/// method yields a JSON-RPC error response, **never a panic** and never a
/// dropped connection. Notifications (no `id`) are processed without a reply.
///
/// **Full-duplex / non-blocking (anti-wedge).** A request whose handling blocks —
/// notably `idea_ask_agent`, which awaits another agent's `idea_reply` in a
/// synchronous rendezvous — must **not** stall the read loop: otherwise a single
/// in-flight ask parks the whole connection and every later call (even a
/// rendezvous-free `idea_list_agents`) is never even read. So each inbound message
/// is handled on its own spawned task that owns a cheap per-call clone of the
/// server; finished responses are funnelled back through an `mpsc` channel and
/// written by the same loop. Responses carry their JSON-RPC `id`, so out-of-order
/// completion is fine (the full-duplex bridge correlates by id). Notifications
/// (`handle_raw` ⇒ `None`) emit nothing.
pub async fn serve<T: Transport>(&self, transport: &mut T) {
let (tx, mut rx) = mpsc::unbounded_channel::<Option<Vec<u8>>>();
// Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an
// EOF on the read side must NOT exit immediately: we keep draining `rx` so
// already-computed responses still reach the transport (graceful shutdown).
// A response-less notification decrements without ever sending — see below.
let mut in_flight: usize = 0;
// Once the peer closed its read side, stop accepting new inbound; only drain.
let mut reading = true;
loop {
let raw = match transport.recv().await {
Ok(bytes) => bytes,
Err(TransportError::Closed) => break,
Err(TransportError::Io(_)) => break,
};
if let Some(response) = self.handle_raw(&raw).await {
let Ok(bytes) = serde_json::to_vec(&response) else {
continue;
};
if transport.send(&bytes).await.is_err() {
break;
tokio::select! {
// Drain ready responses first so a flood of inbound never starves
// writes; correctness does not depend on the bias.
biased;
outbound = rx.recv() => {
// `tx` is held by the loop, so `recv` only yields `None` once it
// is dropped — which never happens before the loop returns.
let Some(slot) = outbound else { break };
in_flight -= 1;
if let Some(bytes) = slot {
if transport.send(&bytes).await.is_err() {
break;
}
}
// Peer gone and every spawned task accounted for ⇒ done.
if !reading && in_flight == 0 {
break;
}
}
incoming = transport.recv(), if reading => {
let raw = match incoming {
Ok(bytes) => bytes,
// Peer closed / I/O error: stop reading but keep draining the
// responses of tasks still in flight before returning.
Err(TransportError::Closed) | Err(TransportError::Io(_)) => {
reading = false;
if in_flight == 0 {
break;
}
continue;
}
};
// Own a cheap clone (Arc/String/Project) so the task is `'static`
// and never borrows the transport. Every spawned task sends
// exactly one slot back (`Some(bytes)` for a reply, `None` for a
// notification) so `in_flight` is always reconciled.
in_flight += 1;
let server = self.for_requester(self.requester.clone());
let tx = tx.clone();
tokio::spawn(async move {
let slot = match server.handle_raw(&raw).await {
Some(response) => serde_json::to_vec(&response).ok(),
None => None,
};
// Receiver dropped ⇒ the loop has stopped; discard.
let _ = tx.send(slot);
});
}
}
}
@ -176,7 +285,10 @@ impl McpServer {
params: Option<Value>,
) -> Result<Value, JsonRpcError> {
match method {
"initialize" => Ok(self.initialize_result()),
"initialize" => {
self.notify_ready();
Ok(self.initialize_result())
}
"tools/list" => Ok(self.tools_list_result()),
"tools/call" => self.tools_call(params.unwrap_or(Value::Null)).await,
other => Err(JsonRpcError::new(
@ -186,6 +298,16 @@ impl McpServer {
}
}
/// Notifie le sink de readiness de démarrage avec l'identité du peer connecté
/// (handshake `requester`). No-op si pas de sink ou requester vide (peer legacy/anonyme).
fn notify_ready(&self) {
if let Some(sink) = &self.ready_sink {
if !self.requester.is_empty() {
sink(&self.requester);
}
}
}
/// The `initialize` result: protocol version, server identity, and the fact
/// that we expose tools.
fn initialize_result(&self) -> Value {
@ -228,7 +350,25 @@ impl McpServer {
let command =
tools::map_tool_call(&name, &arguments, &self.requester).map_err(map_err_to_jsonrpc)?;
let result = self.service.dispatch(&self.project, command).await;
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous
// (the target's `idea_reply`). Bound *only* that path with a finite outer
// timeout (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`]): on expiry
// return a clean JSON-RPC error instead of parking the task forever. Every
// other tool dispatches unbounded — they never rendezvous.
let dispatch = self.service.dispatch(&self.project, command);
let result = if name == "idea_ask_agent" {
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
Ok(result) => result,
Err(_elapsed) => {
return Err(JsonRpcError::new(
error_codes::INTERNAL_ERROR,
"idea_ask_agent : aucune réponse de la cible avant le timeout du rendezvous",
));
}
}
} else {
dispatch.await
};
// Surface the processed delegation on the bus, tagged as the MCP door — the
// twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the
// action is the tool name. No-op when no sink is attached.