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:
@ -52,7 +52,7 @@ use application::{
|
||||
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
|
||||
OrchestratorService, TerminalSessions, UpdateAgentContext,
|
||||
};
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
|
||||
use infrastructure::orchestrator::mcp::jsonrpc::{error_codes, Transport, TransportError};
|
||||
use infrastructure::{
|
||||
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
|
||||
SystemMillisClock,
|
||||
@ -859,6 +859,45 @@ async fn initialize_answers_minimal_handshake() {
|
||||
assert_eq!(result["serverInfo"]["name"], json!("idea-orchestrator"));
|
||||
}
|
||||
|
||||
/// Readiness de démarrage (fix race cold-launch, signal MCP) : un `initialize` reçu sur
|
||||
/// un serveur per-connexion (`for_requester(id)`) déclenche le `ready_sink` avec l'id du
|
||||
/// peer. Et un serveur SANS requester (anonyme) ne déclenche PAS le sink (peer legacy).
|
||||
#[tokio::test]
|
||||
async fn initialize_fires_ready_sink_with_requester() {
|
||||
let (service, _s) = build_service(FakeContexts::new());
|
||||
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_buf = Arc::clone(&captured);
|
||||
let ready_sink: Arc<dyn Fn(&str) + Send + Sync> =
|
||||
Arc::new(move |req: &str| sink_buf.lock().unwrap().push(req.to_owned()));
|
||||
|
||||
let base = McpServer::new(service, project()).with_ready_sink(ready_sink);
|
||||
|
||||
let init = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0", "id": 0, "method": "initialize",
|
||||
"params": { "protocolVersion": "2024-11-05" }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// Cas 1 : peer identifié (requester non vide) ⇒ le sink reçoit son id.
|
||||
let peer = base.for_requester("agent-42");
|
||||
let response = peer.handle_raw(&init).await.expect("reply owed");
|
||||
assert!(response.error.is_none(), "initialize must still answer");
|
||||
assert_eq!(
|
||||
*captured.lock().unwrap(),
|
||||
vec!["agent-42".to_owned()],
|
||||
"initialize d'un peer identifié ⇒ ready_sink appelé avec son requester"
|
||||
);
|
||||
|
||||
// Cas 2 : peer anonyme (requester vide, le serveur de base) ⇒ sink NON appelé.
|
||||
captured.lock().unwrap().clear();
|
||||
let response = base.handle_raw(&init).await.expect("reply owed");
|
||||
assert!(response.error.is_none());
|
||||
assert!(
|
||||
captured.lock().unwrap().is_empty(),
|
||||
"requester vide ⇒ ready_sink jamais appelé (peer legacy/anonyme)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Shared validation: a tools/call missing a required argument is rejected by
|
||||
// the SAME OrchestratorRequest::validate, with no dispatch.
|
||||
@ -1049,3 +1088,165 @@ async fn server_without_event_sink_emits_nothing_and_does_not_panic() {
|
||||
// The command really ran (agent created) — proof the no-op sink path is live.
|
||||
assert_eq!(contexts.entries().len(), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Anti-wedge (régression du bug serveur lockstep) + timeout du rendezvous.
|
||||
//
|
||||
// Cœur de la régression : un `idea_ask_agent` en attente de son `idea_reply`
|
||||
// ne doit PLUS parquer toute la connexion. La preuve : pendant qu'un ask est
|
||||
// bloqué (rendezvous jamais résolu), un second appel (`tools/list`) sur la
|
||||
// MÊME connexion reçoit sa réponse. L'ancien `serve` lockstep ne lisait plus
|
||||
// rien tant que `handle_raw` de l'ask n'avait pas rendu → ce test bouclait.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Transport scriptable qui **reste ouvert** : il livre `inbound` dans l'ordre,
|
||||
/// puis, une fois la file vide, `recv` reste en attente (au lieu de fermer) tant
|
||||
/// que le test n'a pas appelé `close()`. Cela laisse la boucle `serve` vivante —
|
||||
/// donc capable de drainer les réponses des tâches encore en vol — pendant qu'un
|
||||
/// `idea_ask_agent` est parqué. Les `send` sont capturés sur un canal `mpsc`.
|
||||
struct GatedTransport {
|
||||
inbound: std::collections::VecDeque<Vec<u8>>,
|
||||
outbound: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
|
||||
close: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl GatedTransport {
|
||||
fn new(
|
||||
messages: Vec<Vec<u8>>,
|
||||
) -> (
|
||||
Self,
|
||||
tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
Arc<tokio::sync::Notify>,
|
||||
) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let close = Arc::new(tokio::sync::Notify::new());
|
||||
(
|
||||
Self {
|
||||
inbound: messages.into(),
|
||||
outbound: tx,
|
||||
close: Arc::clone(&close),
|
||||
},
|
||||
rx,
|
||||
close,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for GatedTransport {
|
||||
async fn recv(&mut self) -> Result<Vec<u8>, TransportError> {
|
||||
if let Some(next) = self.inbound.pop_front() {
|
||||
return Ok(next);
|
||||
}
|
||||
// File vide : on n'émet PAS Closed tout de suite — on attend le signal de
|
||||
// fermeture pour ne pas tuer la boucle pendant qu'un ask est encore parqué.
|
||||
self.close.notified().await;
|
||||
Err(TransportError::Closed)
|
||||
}
|
||||
|
||||
async fn send(&mut self, message: &[u8]) -> Result<(), TransportError> {
|
||||
self.outbound
|
||||
.send(message.to_vec())
|
||||
.map_err(|_| TransportError::Closed)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_ask_does_not_wedge_the_connection_concurrent_call_still_answered() {
|
||||
// Un `idea_ask_agent` vers une cible vivante bloque sur la mailbox (aucun
|
||||
// `idea_reply` ne viendra). Sur la MÊME connexion, un `tools/list` qui suit
|
||||
// doit recevoir sa réponse SANS attendre la résolution de l'ask.
|
||||
let contexts = FakeContexts::new();
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, mailbox, sessions) = build_service_with_mailbox(contexts);
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(909)),
|
||||
);
|
||||
let server = server(service);
|
||||
|
||||
let ask = tools_call(
|
||||
1,
|
||||
"idea_ask_agent",
|
||||
json!({ "target": "architect", "task": "blocking..." }),
|
||||
);
|
||||
let list = serde_json::to_vec(&json!({
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/list"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let (transport, mut rx, close) = GatedTransport::new(vec![ask, list]);
|
||||
let serve = tokio::spawn(async move {
|
||||
let mut transport = transport;
|
||||
server.serve(&mut transport).await;
|
||||
});
|
||||
|
||||
// L'ask a bien été enqueué (donc il est parqué en attente de reply).
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
while mailbox.pending(&agent_id) == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("ask must enqueue a ticket");
|
||||
|
||||
// La réponse au `tools/list` arrive AVANT que l'ask soit résolu : c'est la
|
||||
// preuve anti-wedge. (Sur l'ancien code lockstep, ce recv timeout-ait.)
|
||||
let response = tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv())
|
||||
.await
|
||||
.expect("tools/list must be answered while the ask is still pending")
|
||||
.expect("a response payload");
|
||||
let response: Value = serde_json::from_slice(&response).unwrap();
|
||||
assert_eq!(response["id"], json!(2), "the answered call is tools/list");
|
||||
assert!(
|
||||
response["result"]["tools"].is_array(),
|
||||
"tools/list result, got {response}"
|
||||
);
|
||||
|
||||
// L'ask est toujours en vol (non résolu, aucun `idea_reply` ne viendra) : la
|
||||
// boucle restera en attente de sa réponse, c'est attendu. On la ferme et on
|
||||
// abandonne la tâche serve (le rendezvous parqué ne se résoudra jamais ici).
|
||||
assert_eq!(mailbox.pending(&agent_id), 1, "ask still pending");
|
||||
close.notify_one();
|
||||
serve.abort();
|
||||
let _ = serve.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() {
|
||||
// La cible ne répondra jamais. Avec une borne courte injectée, l'ask doit
|
||||
// finir par renvoyer une ERREUR JSON-RPC propre (filet de sécurité serveur),
|
||||
// au lieu de pendre indéfiniment.
|
||||
let contexts = FakeContexts::new();
|
||||
let agent_id = contexts.seed_agent("architect");
|
||||
let (service, _mailbox, sessions) = build_service_with_mailbox(contexts);
|
||||
seed_live_pty(
|
||||
&sessions,
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(910)),
|
||||
);
|
||||
let server = server(service)
|
||||
.with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
|
||||
|
||||
let raw = tools_call(
|
||||
1,
|
||||
"idea_ask_agent",
|
||||
json!({ "target": "architect", "task": "never answered" }),
|
||||
);
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
server.handle_raw(&raw),
|
||||
)
|
||||
.await
|
||||
.expect("must not hang past the injected timeout")
|
||||
.expect("reply owed");
|
||||
|
||||
let error = response.error.expect("a JSON-RPC error on timeout");
|
||||
assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}");
|
||||
assert!(
|
||||
error.message.contains("timeout"),
|
||||
"explicit timeout message, got {error:?}"
|
||||
);
|
||||
assert!(response.result.is_none(), "error responses carry no result");
|
||||
}
|
||||
|
||||
86
crates/infrastructure/tests/permission_store.rs
Normal file
86
crates/infrastructure/tests/permission_store.rs
Normal file
@ -0,0 +1,86 @@
|
||||
//! L2 integration tests for [`FsPermissionStore`] against a real temp project.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{FileSystem, PermissionStore};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{
|
||||
AgentPermissionOverride, PermissionSet, Posture, ProjectPermissions, PERMISSIONS_VERSION,
|
||||
};
|
||||
use infrastructure::{FsPermissionStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch project directory under the OS temp dir, cleaned up on drop.
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l2-permissions-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
|
||||
fn project_root(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> FsPermissionStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsPermissionStore::new(fs)
|
||||
}
|
||||
|
||||
fn project(tmp: &TempDir) -> Project {
|
||||
Project::new(
|
||||
ProjectId::new_random(),
|
||||
"permissions",
|
||||
ProjectPath::new(tmp.project_root()).unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_permissions_file_returns_default_document() {
|
||||
let tmp = TempDir::new();
|
||||
let project = project(&tmp);
|
||||
|
||||
let loaded = store().load_permissions(&project).await.unwrap();
|
||||
|
||||
assert_eq!(loaded, ProjectPermissions::default());
|
||||
assert_eq!(loaded.version, PERMISSIONS_VERSION);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_load_roundtrips_project_defaults_and_agent_override() {
|
||||
let tmp = TempDir::new();
|
||||
let project = project(&tmp);
|
||||
let agent = AgentId::new_random();
|
||||
let doc = ProjectPermissions::new(
|
||||
Some(PermissionSet::new(vec![], Posture::Ask)),
|
||||
vec![AgentPermissionOverride::new(
|
||||
agent,
|
||||
PermissionSet::new(vec![], Posture::Deny),
|
||||
)],
|
||||
);
|
||||
|
||||
let store = store();
|
||||
store.save_permissions(&project, &doc).await.unwrap();
|
||||
|
||||
let loaded = store.load_permissions(&project).await.unwrap();
|
||||
assert_eq!(loaded, doc);
|
||||
assert_eq!(loaded.resolve_for(agent).unwrap().fallback(), Posture::Deny);
|
||||
|
||||
let path = tmp.0.join(".ideai").join("permissions.json");
|
||||
assert!(path.exists(), "store writes under .ideai/permissions.json");
|
||||
}
|
||||
Reference in New Issue
Block a user