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:
@ -54,6 +54,15 @@ impl FileSystem for LocalFileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
match fs::remove_file(path.as_str()).await {
|
||||
Ok(()) => Ok(()),
|
||||
// Idempotent best-effort delete: an already-absent file is success.
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(map_io(path, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
fs::create_dir_all(path.as_str())
|
||||
.await
|
||||
|
||||
@ -44,9 +44,57 @@ struct BusyTracker {
|
||||
/// stagnation issu du profil, et état de vivacité courant pour n'émettre
|
||||
/// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam).
|
||||
liveness: Mutex<HashMap<AgentId, LivenessState>>,
|
||||
/// **Démarrage à froid** (fix race cold-launch) : ensemble des agents fraîchement
|
||||
/// lancés à froid pour lesquels la livraison du **premier** tour doit être *gatée*
|
||||
/// sur le prompt-ready watcher. Un agent y est inscrit par
|
||||
/// [`BusyTracker::mark_starting`] (uniquement quand un watcher est effectivement
|
||||
/// armé), puis consommé par l'`enqueue` qui démarre le tour : la `DelegationReady`
|
||||
/// est alors **différée** dans `deferred` au lieu d'être publiée immédiatement (le
|
||||
/// CLI n'a pas encore affiché son prompt). Vide ⇒ comportement chaud inchangé.
|
||||
starting: Mutex<HashSet<AgentId>>,
|
||||
/// **Tour différé** (fix race cold-launch) : payload de la `DelegationReady` retenue
|
||||
/// pour un démarrage à froid, publiée par [`BusyTracker::prompt_ready`] à l'apparition
|
||||
/// du prompt (jamais avant). Absent ⇒ aucun tour en attente de gate.
|
||||
deferred: Mutex<HashMap<AgentId, DeferredDelegation>>,
|
||||
events: Option<Arc<dyn EventBus>>,
|
||||
/// Sink de livraison headless (cf. [`HeadlessSink`]). Câblé par
|
||||
/// [`MediatedInbox::with_pty`]/[`MediatedInbox::with_events`] quand un PTY est
|
||||
/// présent ; `None` ⇒ toute livraison passe par l'événement (médiateur sans PTY,
|
||||
/// utilisé par les tests événementiels — comportement historique).
|
||||
headless_sink: Option<HeadlessSink>,
|
||||
}
|
||||
|
||||
/// Payload d'une [`DomainEvent::DelegationReady`] **différée** le temps qu'un agent
|
||||
/// lancé à froid atteigne son prompt (fix race cold-launch). Reconstruite à l'identique
|
||||
/// quand le prompt-ready watcher déclenche, de sorte que le premier tour soit livré au
|
||||
/// bon moment (et un seul `DelegationReady`, comme pour un agent chaud).
|
||||
#[derive(Debug, Clone)]
|
||||
struct DeferredDelegation {
|
||||
ticket: TicketId,
|
||||
text: String,
|
||||
submit_sequence: Option<String>,
|
||||
submit_delay_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Sink de livraison « headless » optionnel branché sur le [`BusyTracker`].
|
||||
///
|
||||
/// Appelé pour **chaque** tour à livrer (chemin chaud immédiat comme drains à froid).
|
||||
/// Reçoit l'agent + le payload de délégation et décide :
|
||||
/// - `None` ⇒ il a **pris en charge** la livraison (écriture directe dans le PTY d'un
|
||||
/// agent headless qui n'a pas de cellule frontend) ;
|
||||
/// - `Some(d)` ⇒ il **rend la main** : le tracker publie alors le `DelegationReady`
|
||||
/// normal (cellule frontend présente ⇒ c'est le write-portal qui écrira, ou pas de
|
||||
/// PTY/handle disponible ⇒ repli sur l'événement, comportement historique).
|
||||
type HeadlessSink =
|
||||
Arc<dyn Fn(AgentId, DeferredDelegation) -> Option<DeferredDelegation> + Send + Sync>;
|
||||
|
||||
/// Délai (ms) entre l'écriture du texte de la tâche et celle de la séquence de
|
||||
/// soumission lors d'une livraison **headless** (le médiateur écrit lui-même le PTY).
|
||||
/// Aligné sur le défaut du write-portal frontend (`DEFAULT_SUBMIT_DELAY_MS`) : sépare
|
||||
/// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement
|
||||
/// quand le profil de la cible ne fournit pas de `submit_delay_ms`.
|
||||
const DEFAULT_SUBMIT_DELAY_MS: u32 = 60;
|
||||
|
||||
/// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2).
|
||||
///
|
||||
/// Pur (aucune I/O) : un timestamp `last_seen_ms` rafraîchi à chaque battement, le
|
||||
@ -68,10 +116,36 @@ impl BusyTracker {
|
||||
Self {
|
||||
busy: Mutex::new(HashMap::new()),
|
||||
liveness: Mutex::new(HashMap::new()),
|
||||
starting: Mutex::new(HashSet::new()),
|
||||
deferred: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
headless_sink: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_starting(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> {
|
||||
self.starting
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn lock_deferred(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, DeferredDelegation>> {
|
||||
self.deferred
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Marque un agent **en démarrage à froid** : son tout premier tour devra être
|
||||
/// *gaté* sur le prompt-ready watcher (la `DelegationReady` sera différée à
|
||||
/// l'apparition du prompt). À n'appeler **que** lorsqu'un watcher est effectivement
|
||||
/// armé (un `prompt_ready_pattern` non vide est configuré), sinon le premier tour
|
||||
/// resterait bloqué indéfiniment (aucun signal ne viendrait le libérer). Sans cet
|
||||
/// appel, l'`enqueue` publie la `DelegationReady` immédiatement (chemin chaud,
|
||||
/// zéro régression).
|
||||
fn mark_starting(&self, agent: AgentId) {
|
||||
self.lock_starting().insert(agent);
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, AgentBusyState>> {
|
||||
self.busy
|
||||
.lock()
|
||||
@ -192,6 +266,62 @@ impl BusyTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publie une [`DomainEvent::DelegationReady`] depuis un payload différé (si un bus
|
||||
/// est câblé). Utilisée pour livrer le **premier** tour d'un agent froid au moment
|
||||
/// où son prompt apparaît.
|
||||
fn publish_deferred(&self, agent: AgentId, d: DeferredDelegation) {
|
||||
// Point de livraison unique (tours chauds immédiats ET drains à froid). Si un
|
||||
// sink headless est câblé, il a la priorité : pour un agent sans cellule
|
||||
// frontend il écrit lui-même la tâche dans le PTY et renvoie `None` (pris en
|
||||
// charge) ; sinon il renvoie `Some(d)` et on retombe sur l'événement.
|
||||
let d = match &self.headless_sink {
|
||||
Some(sink) => match sink(agent, d) {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
},
|
||||
None => d,
|
||||
};
|
||||
if let Some(events) = &self.events {
|
||||
events.publish(DomainEvent::DelegationReady {
|
||||
agent_id: agent,
|
||||
ticket: d.ticket,
|
||||
text: d.text,
|
||||
submit_sequence: d.submit_sequence,
|
||||
submit_delay_ms: d.submit_delay_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal **prompt-ready** émis par le watcher à l'apparition du marqueur.
|
||||
///
|
||||
/// - Si un tour de démarrage à froid est **différé** pour cet agent : c'est le
|
||||
/// moment de le livrer ⇒ on publie la `DelegationReady` retenue et l'agent **reste
|
||||
/// Busy** (son premier tour court désormais réellement). Le tour ne se terminera
|
||||
/// que sur un `idea_reply` ou un prochain prompt-ready (qui, lui, fera `mark_idle`).
|
||||
/// - Sinon : comportement historique ⇒ `mark_idle` (fait avancer la FIFO).
|
||||
fn prompt_ready(&self, agent: AgentId) {
|
||||
// On ne retire l'agent de `starting` qu'ici : tant que son premier tour n'a pas
|
||||
// été livré, un re-arming éventuel doit rester gaté.
|
||||
self.lock_starting().remove(&agent);
|
||||
let deferred = self.lock_deferred().remove(&agent);
|
||||
match deferred {
|
||||
Some(d) => self.publish_deferred(agent, d),
|
||||
None => self.mark_idle(agent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Libère un premier tour différé sur **readiness de démarrage** (connexion du pont
|
||||
/// MCP de l'agent). Draine le `DeferredDelegation` retenu s'il existe (et retire
|
||||
/// l'agent de `starting`) ; sinon no-op. **Pas de `mark_idle`** (signal de démarrage,
|
||||
/// pas de fin de tour). Idempotent et OR-safe avec `prompt_ready` (le `remove` ne rend
|
||||
/// `Some` qu'une fois).
|
||||
fn release_cold_start(&self, agent: AgentId) {
|
||||
self.lock_starting().remove(&agent);
|
||||
if let Some(d) = self.lock_deferred().remove(&agent) {
|
||||
self.publish_deferred(agent, d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks `agent` `Idle`, publishing `AgentBusyChanged{busy:false}` only on a real
|
||||
/// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a
|
||||
/// no-op and emits nothing.
|
||||
@ -256,7 +386,15 @@ pub struct MediatedInbox {
|
||||
/// observe an agent's output stream for prompt-ready detection (lot C5).
|
||||
pty: Option<Arc<dyn PtyPort>>,
|
||||
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
|
||||
handles: Mutex<HashMap<AgentId, PtyHandle>>,
|
||||
/// `Arc` so the headless delivery sink (wired into the [`BusyTracker`]) can read it
|
||||
/// to resolve an agent's PTY handle when it must write the turn itself.
|
||||
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
|
||||
/// Agents qui ont une **cellule terminal frontend montée** (write-portal actif),
|
||||
/// tenu à jour par [`InputMediator::set_front_attached`]. Quand un agent y figure,
|
||||
/// la livraison passe par l'événement `DelegationReady` (le front écrit) ; sinon
|
||||
/// (agent headless / délégué en arrière-plan) le médiateur écrit lui-même le tour
|
||||
/// dans le PTY. `Arc` car le sink headless du tracker le consulte.
|
||||
front_owned: Arc<Mutex<HashSet<AgentId>>>,
|
||||
/// Per-agent submit config (target profile's `submit_sequence`/`submit_delay_ms`),
|
||||
/// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
|
||||
/// turn starts. Absent ⇒ both `None` (the front applies its defaults).
|
||||
@ -276,24 +414,103 @@ impl MediatedInbox {
|
||||
/// turn delivery (the orchestrator writes the turn itself).
|
||||
#[must_use]
|
||||
pub fn new(mailbox: Arc<InMemoryMailbox>, clock: Arc<dyn MillisClock>) -> Self {
|
||||
let handles = Arc::new(Mutex::new(HashMap::new()));
|
||||
let front_owned = Arc::new(Mutex::new(HashSet::new()));
|
||||
Self {
|
||||
mailbox,
|
||||
tracker: Arc::new(BusyTracker::new(None)),
|
||||
tracker: Self::build_tracker(None, None, &handles, &front_owned),
|
||||
clock,
|
||||
pty: None,
|
||||
handles: Mutex::new(HashMap::new()),
|
||||
handles,
|
||||
front_owned,
|
||||
submit: Mutex::new(HashMap::new()),
|
||||
stall: Mutex::new(HashMap::new()),
|
||||
watched: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le [`BusyTracker`] avec, quand un PTY est présent, le **sink headless**
|
||||
/// branché (cf. [`HeadlessSink`]). Sans PTY ⇒ aucun sink (livraison par événement).
|
||||
fn build_tracker(
|
||||
events: Option<Arc<dyn EventBus>>,
|
||||
pty: Option<&Arc<dyn PtyPort>>,
|
||||
handles: &Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
|
||||
front_owned: &Arc<Mutex<HashSet<AgentId>>>,
|
||||
) -> Arc<BusyTracker> {
|
||||
let mut tracker = BusyTracker::new(events);
|
||||
if let Some(pty) = pty {
|
||||
tracker.headless_sink = Some(Self::make_headless_sink(
|
||||
Arc::clone(pty),
|
||||
Arc::clone(handles),
|
||||
Arc::clone(front_owned),
|
||||
));
|
||||
}
|
||||
Arc::new(tracker)
|
||||
}
|
||||
|
||||
/// Fabrique le sink de livraison headless : pour un agent **sans cellule frontend**
|
||||
/// (absent de `front_owned`), écrit la tâche dans son PTY (texte, puis — après le
|
||||
/// délai anti-paste-detection — la séquence de soumission) et renvoie `None` (pris
|
||||
/// en charge). Pour un agent avec cellule, ou sans handle PTY connu, renvoie
|
||||
/// `Some(d)` ⇒ le tracker publie l'événement `DelegationReady` comme avant.
|
||||
fn make_headless_sink(
|
||||
pty: Arc<dyn PtyPort>,
|
||||
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>,
|
||||
front_owned: Arc<Mutex<HashSet<AgentId>>>,
|
||||
) -> HeadlessSink {
|
||||
Arc::new(move |agent: AgentId, d: DeferredDelegation| {
|
||||
// Cellule frontend montée ⇒ c'est le write-portal qui écrit (et qui sait
|
||||
// composer avec une saisie humaine en cours). On rend la main.
|
||||
if front_owned
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.contains(&agent)
|
||||
{
|
||||
return Some(d);
|
||||
}
|
||||
// Agent headless : récupérer son handle PTY. Absent ⇒ repli sur l'événement
|
||||
// (best-effort, ne devrait pas arriver pour un agent qui livre un tour).
|
||||
let handle = handles
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&agent)
|
||||
.cloned();
|
||||
let Some(handle) = handle else {
|
||||
return Some(d);
|
||||
};
|
||||
let pty = Arc::clone(&pty);
|
||||
let text = d.text;
|
||||
let submit = d.submit_sequence.unwrap_or_else(|| "\r".to_owned());
|
||||
let delay = u64::from(d.submit_delay_ms.unwrap_or(DEFAULT_SUBMIT_DELAY_MS));
|
||||
// Écriture sur un thread détaché : ne JAMAIS bloquer l'appelant (thread du
|
||||
// watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue).
|
||||
// Texte d'abord, puis la séquence de soumission après le délai — comme le
|
||||
// write-portal frontend — pour esquiver la détection de coller des CLI.
|
||||
std::thread::spawn(move || {
|
||||
let _ = pty.write(&handle, text.as_bytes());
|
||||
if delay > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
||||
}
|
||||
let _ = pty.write(&handle, submit.as_bytes());
|
||||
});
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Wires an [`EventBus`] so busy/idle transitions publish
|
||||
/// [`DomainEvent::AgentBusyChanged`] at their source (cadrage C4 §4.2). Builder
|
||||
/// additive: callers that do not wire a bus stay silent.
|
||||
#[must_use]
|
||||
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
|
||||
self.tracker = Arc::new(BusyTracker::new(Some(events)));
|
||||
// Reconstruit le tracker avec le bus ET — si un PTY est déjà câblé (cas
|
||||
// `with_pty().with_events()` du composition root) — le sink headless, sinon
|
||||
// l'ordre des builders perdrait la livraison headless.
|
||||
self.tracker = Self::build_tracker(
|
||||
Some(events),
|
||||
self.pty.as_ref(),
|
||||
&self.handles,
|
||||
&self.front_owned,
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
@ -306,12 +523,16 @@ impl MediatedInbox {
|
||||
clock: Arc<dyn MillisClock>,
|
||||
pty: Arc<dyn PtyPort>,
|
||||
) -> Self {
|
||||
let handles = Arc::new(Mutex::new(HashMap::new()));
|
||||
let front_owned = Arc::new(Mutex::new(HashSet::new()));
|
||||
let pty_opt = Some(pty);
|
||||
Self {
|
||||
mailbox,
|
||||
tracker: Arc::new(BusyTracker::new(None)),
|
||||
tracker: Self::build_tracker(None, pty_opt.as_ref(), &handles, &front_owned),
|
||||
clock,
|
||||
pty: Some(pty),
|
||||
handles: Mutex::new(HashMap::new()),
|
||||
pty: pty_opt,
|
||||
handles,
|
||||
front_owned,
|
||||
submit: Mutex::new(HashMap::new()),
|
||||
stall: Mutex::new(HashMap::new()),
|
||||
watched: Arc::new(Mutex::new(HashSet::new())),
|
||||
@ -409,8 +630,10 @@ impl MediatedInbox {
|
||||
for chunk in stream {
|
||||
window.extend_from_slice(&chunk);
|
||||
if window.windows(needle.len()).any(|w| w == needle.as_slice()) {
|
||||
// Prompt-ready: first OR signal wins ⇒ Idle (advances the FIFO).
|
||||
tracker.mark_idle(agent);
|
||||
// Prompt-ready: premier signal OR gagnant. Si un premier tour froid
|
||||
// est différé ⇒ on le **livre** ici (et l'agent reste Busy) ; sinon
|
||||
// ⇒ `mark_idle` (fait avancer la FIFO). Voir [`BusyTracker::prompt_ready`].
|
||||
tracker.prompt_ready(agent);
|
||||
break;
|
||||
}
|
||||
if window.len() > keep {
|
||||
@ -476,21 +699,32 @@ impl InputMediator for MediatedInbox {
|
||||
busy: true,
|
||||
});
|
||||
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
|
||||
events.publish(DomainEvent::DelegationReady {
|
||||
agent_id: agent,
|
||||
// Préfixe de délégation : c'est le **signal** qui dit à la cible
|
||||
// « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en
|
||||
// texte) en renvoyant ce `ticket` ». Sans lui la cible traite la
|
||||
// ligne comme une invite humaine et répond dans le terminal, et
|
||||
// l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute
|
||||
// reste dans le `Ticket` (mailbox/historique) ; seul le texte livré
|
||||
// au PTY porte le préfixe. Format aligné sur l'instruction injectée
|
||||
// dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`).
|
||||
let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task);
|
||||
// **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit
|
||||
// par `mark_starting` quand un watcher est armé), ON DIFFÈRE la
|
||||
// `DelegationReady` jusqu'au prompt-ready (le CLI n'a pas encore affiché
|
||||
// son prompt — la livrer maintenant perdrait le premier tour). Le watcher
|
||||
// la publiera via `prompt_ready`. Agent chaud (pas dans `starting`) ⇒
|
||||
// publication immédiate, comportement inchangé.
|
||||
let deferred = DeferredDelegation {
|
||||
ticket: ticket_id,
|
||||
// Préfixe de délégation : c'est le **signal** qui dit à la cible
|
||||
// « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en
|
||||
// texte) en renvoyant ce `ticket` ». Sans lui la cible traite la
|
||||
// ligne comme une invite humaine et répond dans le terminal, et
|
||||
// l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute
|
||||
// reste dans le `Ticket` (mailbox/historique) ; seul le texte livré
|
||||
// au PTY porte le préfixe. Format aligné sur l'instruction injectée
|
||||
// dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`).
|
||||
text: delegation_preamble(&ticket.requester, ticket_id, &ticket.task),
|
||||
text,
|
||||
submit_sequence: submit.sequence,
|
||||
submit_delay_ms: submit.delay_ms,
|
||||
});
|
||||
};
|
||||
if self.tracker.lock_starting().contains(&agent) {
|
||||
self.tracker.lock_deferred().insert(agent, deferred);
|
||||
} else {
|
||||
self.tracker.publish_deferred(agent, deferred);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mailbox.enqueue(agent, ticket)
|
||||
@ -523,6 +757,30 @@ impl InputMediator for MediatedInbox {
|
||||
self.pty.is_some() && self.handles().get(&agent).is_some()
|
||||
}
|
||||
|
||||
fn mark_starting(&self, agent: AgentId) {
|
||||
// Gate du premier tour d'un agent froid : appelé par l'orchestrateur juste après
|
||||
// un (re)lancement à froid, AVANT le bind/enqueue, et uniquement si un watcher
|
||||
// sera armé (cf. doc du trait). Consommé par l'`enqueue` qui démarre le tour
|
||||
// (diffère la `DelegationReady`) puis par `prompt_ready` (la libère).
|
||||
self.tracker.mark_starting(agent);
|
||||
}
|
||||
|
||||
fn release_cold_start(&self, agent: AgentId) {
|
||||
self.tracker.release_cold_start(agent);
|
||||
}
|
||||
|
||||
fn set_front_attached(&self, agent: AgentId, attached: bool) {
|
||||
let mut front = self
|
||||
.front_owned
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if attached {
|
||||
front.insert(agent);
|
||||
} else {
|
||||
front.remove(&agent);
|
||||
}
|
||||
}
|
||||
|
||||
fn preempt(&self, agent: AgentId) {
|
||||
// Interrompre: signals the running turn to stop. It is NOT an enqueue and
|
||||
// correlates **no** ticket (we never pop/resolve a pending caller — preempt
|
||||
@ -761,6 +1019,9 @@ mod tests {
|
||||
None,
|
||||
SubmitConfig::new(Some("\r".to_owned()), Some(42)),
|
||||
);
|
||||
// A frontend cell is mounted ⇒ delivery flows through the event (the front
|
||||
// writes). This test asserts the event carries the bound submit config.
|
||||
inbox.set_front_attached(a, true);
|
||||
inbox.enqueue(a, ticket(10, "task"));
|
||||
|
||||
let ready = bus.delegation_ready();
|
||||
@ -786,24 +1047,71 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_does_not_write_into_the_pty() {
|
||||
// The whole point of §20: the backend stops writing the turn into the PTY.
|
||||
fn front_attached_agent_is_delivered_via_event_not_backend_write() {
|
||||
// §20 nominal: a mounted frontend cell owns the physical write. The backend
|
||||
// publishes DelegationReady and writes NOTHING into the PTY itself.
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let inbox = MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
||||
);
|
||||
)
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
let h = handle(1);
|
||||
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
|
||||
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default());
|
||||
inbox.set_front_attached(a, true);
|
||||
|
||||
inbox.enqueue(a, ticket(10, "do X"));
|
||||
inbox.enqueue(a, ticket(11, "do Y")); // even a second enqueue must not write
|
||||
|
||||
assert_eq!(
|
||||
bus.delegation_ready().len(),
|
||||
1,
|
||||
"front-attached ⇒ exactly one DelegationReady event for the write-portal"
|
||||
);
|
||||
// The headless path is not taken ⇒ no thread is spawned ⇒ no PTY write, ever.
|
||||
assert!(
|
||||
pty.writes.lock().unwrap().is_empty(),
|
||||
"enqueue must perform NO pty.write (the `\\n` band-aid is gone)"
|
||||
"front-attached ⇒ the backend must NOT write the PTY (the front does)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_agent_without_front_cell_is_written_by_the_backend() {
|
||||
// Cold/background delegation target: no frontend cell is mounted, so NOBODY
|
||||
// consumes DelegationReady. The mediator must therefore write the turn into the
|
||||
// PTY itself (text + submit), else the agent never receives its task — the
|
||||
// root cause of "a delegated agent never replies".
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let inbox = MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
||||
)
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
// No set_front_attached ⇒ headless.
|
||||
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default());
|
||||
|
||||
inbox.enqueue(a, ticket(10, "do X"));
|
||||
|
||||
assert!(
|
||||
wait_until(|| pty.writes.lock().unwrap().len() >= 2),
|
||||
"headless delivery writes the task text + submit sequence into the PTY"
|
||||
);
|
||||
let writes = pty.writes.lock().unwrap().clone();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&writes[0]).contains("do X"),
|
||||
"first write carries the task text"
|
||||
);
|
||||
assert_eq!(
|
||||
writes[1], b"\r",
|
||||
"second write is the default submit sequence"
|
||||
);
|
||||
assert!(
|
||||
bus.delegation_ready().is_empty(),
|
||||
"headless delivery takes over ⇒ no DelegationReady event is published"
|
||||
);
|
||||
}
|
||||
|
||||
@ -1148,12 +1456,252 @@ mod tests {
|
||||
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Fix race cold-launch — `mark_starting` gate le PREMIER tour sur le prompt-ready
|
||||
// ====================================================================
|
||||
|
||||
/// (a) Cold-launch : un agent marqué `mark_starting` ne livre PAS sa
|
||||
/// `DelegationReady` à l'enqueue ; elle est différée jusqu'à l'apparition du prompt
|
||||
/// dans la sortie (le watcher la publie alors), au bon moment. L'agent reste Busy
|
||||
/// pendant tout le gate.
|
||||
#[test]
|
||||
fn cold_launch_defers_first_turn_until_prompt_ready() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let a = agent(1);
|
||||
let h = handle(20);
|
||||
// Sortie de boot : le prompt "\n> " n'apparaît que dans le second chunk.
|
||||
pty.seed(
|
||||
&h,
|
||||
vec![b"booting CLI...\n".to_vec(), b"ready\n> ".to_vec()],
|
||||
);
|
||||
let inbox = MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
||||
)
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
|
||||
// Cellule front montée : ce test observe la livraison via l'événement (chemin
|
||||
// write-portal), orthogonal au gate cold-launch testé ici.
|
||||
inbox.set_front_attached(a, true);
|
||||
// Démarrage à froid : gate armé AVANT bind/enqueue (ordre de l'orchestrateur).
|
||||
inbox.mark_starting(a);
|
||||
inbox.enqueue(a, ticket(10, "cold task"));
|
||||
// L'enqueue a démarré le tour (Busy) MAIS la DelegationReady est différée.
|
||||
assert!(inbox.busy_state(a).is_busy(), "le tour démarre (Busy)");
|
||||
assert!(
|
||||
bus.delegation_ready().is_empty(),
|
||||
"cold-launch ⇒ pas de DelegationReady tant que le prompt n'est pas prêt"
|
||||
);
|
||||
|
||||
// On arme le watcher (le prompt "\n> " finira par apparaître dans la sortie).
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
|
||||
// À l'apparition du prompt : la DelegationReady différée est publiée (1 seule),
|
||||
// et l'agent reste Busy (le premier tour court désormais réellement).
|
||||
assert!(
|
||||
wait_until(|| bus.delegation_ready().len() == 1),
|
||||
"prompt-ready ⇒ la DelegationReady différée est livrée"
|
||||
);
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(
|
||||
ready.len(),
|
||||
1,
|
||||
"exactement une DelegationReady (pas de doublon)"
|
||||
);
|
||||
assert!(
|
||||
ready[0].1.ends_with("\ncold task"),
|
||||
"le texte différé porte la tâche brute: {:?}",
|
||||
ready[0].1
|
||||
);
|
||||
assert!(
|
||||
inbox.busy_state(a).is_busy(),
|
||||
"après livraison du 1er tour froid, l'agent reste Busy (idle viendra d'idea_reply)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Readiness de démarrage MCP : un 1er tour différé (`mark_starting` + enqueue) reste
|
||||
/// retenu tant que rien ne le libère ; `release_cold_start` (signal connexion MCP) le
|
||||
/// draine ⇒ exactement UNE `DelegationReady`. Calque du gate prompt-ready, mais libéré
|
||||
/// par le signal MCP (pas de watcher armé ici).
|
||||
#[test]
|
||||
fn release_cold_start_delivers_deferred_first_turn() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
|
||||
// Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur).
|
||||
inbox.mark_starting(a);
|
||||
inbox.enqueue(a, ticket(10, "cold task"));
|
||||
assert!(inbox.busy_state(a).is_busy(), "le tour démarre (Busy)");
|
||||
assert!(
|
||||
bus.delegation_ready().is_empty(),
|
||||
"cold-launch ⇒ pas de DelegationReady tant que rien ne libère"
|
||||
);
|
||||
|
||||
// Connexion du pont MCP ⇒ libère le 1er tour différé.
|
||||
inbox.release_cold_start(a);
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(
|
||||
ready.len(),
|
||||
1,
|
||||
"release_cold_start ⇒ exactement une DelegationReady"
|
||||
);
|
||||
assert!(
|
||||
ready[0].1.ends_with("\ncold task"),
|
||||
"le texte différé porte la tâche brute: {:?}",
|
||||
ready[0].1
|
||||
);
|
||||
}
|
||||
|
||||
/// `release_cold_start` sans tour différé ⇒ no-op : aucune `DelegationReady` et,
|
||||
/// surtout, PAS de transition Idle (signal de DÉMARRAGE, pas de fin de tour).
|
||||
#[test]
|
||||
fn release_cold_start_is_noop_without_deferred() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
|
||||
// Démarre un tour normal (Busy), SANS gate cold-launch (pas de mark_starting).
|
||||
inbox.enqueue(a, ticket(10, "task"));
|
||||
let busy_before = bus.busy_events();
|
||||
|
||||
inbox.release_cold_start(a);
|
||||
assert!(
|
||||
bus.delegation_ready().len() == 1,
|
||||
"pas de DelegationReady supplémentaire (la seule est celle de l'enqueue)"
|
||||
);
|
||||
assert_eq!(
|
||||
bus.busy_events(),
|
||||
busy_before,
|
||||
"release_cold_start ne marque PAS Idle (aucun AgentBusyChanged{{busy:false}})"
|
||||
);
|
||||
}
|
||||
|
||||
/// `release_cold_start` est idempotent : un second appel ne re-draine rien ⇒ une seule
|
||||
/// `DelegationReady` au total.
|
||||
#[test]
|
||||
fn release_cold_start_is_idempotent() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
|
||||
inbox.mark_starting(a);
|
||||
inbox.enqueue(a, ticket(10, "cold task"));
|
||||
|
||||
inbox.release_cold_start(a);
|
||||
inbox.release_cold_start(a);
|
||||
assert_eq!(
|
||||
bus.delegation_ready().len(),
|
||||
1,
|
||||
"deux release_cold_start ⇒ une seule DelegationReady"
|
||||
);
|
||||
}
|
||||
|
||||
/// (b) Agent chaud (non `mark_starting`) : la `DelegationReady` est publiée
|
||||
/// IMMÉDIATEMENT à l'enqueue — non-régression du chemin existant.
|
||||
#[test]
|
||||
fn warm_agent_delivers_first_turn_immediately() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
|
||||
// Pas de mark_starting ⇒ agent chaud.
|
||||
inbox.enqueue(a, ticket(10, "warm task"));
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(
|
||||
ready.len(),
|
||||
1,
|
||||
"agent chaud ⇒ DelegationReady immédiate (zéro régression)"
|
||||
);
|
||||
assert!(ready[0].1.ends_with("\nwarm task"));
|
||||
}
|
||||
|
||||
/// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le
|
||||
/// gate quand aucun `prompt_ready_pattern` n'est configuré), l'enqueue livre la
|
||||
/// `DelegationReady` immédiatement — donc PAS de blocage indéfini du premier tour.
|
||||
#[test]
|
||||
fn no_prompt_pattern_no_starting_gate_delivers_immediately() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let a = agent(1);
|
||||
let h = handle(21);
|
||||
pty.seed(&h, vec![b"output without any prompt marker\n".to_vec()]);
|
||||
let inbox = MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
||||
)
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
|
||||
// Cellule front montée ⇒ livraison observable via l'événement.
|
||||
inbox.set_front_attached(a, true);
|
||||
// Cold launch SANS pattern ⇒ l'orchestrateur N'APPELLE PAS mark_starting.
|
||||
inbox.enqueue(a, ticket(10, "task"));
|
||||
// Premier tour livré tout de suite : aucun watcher ne viendrait le débloquer.
|
||||
assert_eq!(
|
||||
bus.delegation_ready().len(),
|
||||
1,
|
||||
"sans gate ⇒ DelegationReady immédiate (pas de blocage indéfini)"
|
||||
);
|
||||
// Bind sans pattern : aucun watcher armé (fallback sûr).
|
||||
inbox.bind_handle_with_prompt(a, h, None, SubmitConfig::default());
|
||||
std::thread::sleep(std::time::Duration::from_millis(40));
|
||||
// Reste Busy (idle viendra d'idea_reply / timeout), mais le tour A bien été livré.
|
||||
assert!(inbox.busy_state(a).is_busy());
|
||||
assert_eq!(
|
||||
bus.delegation_ready().len(),
|
||||
1,
|
||||
"toujours une seule livraison"
|
||||
);
|
||||
}
|
||||
|
||||
/// Après livraison du 1er tour froid, un `mark_idle` (idea_reply) puis un nouvel
|
||||
/// enqueue (agent désormais chaud) repart en livraison immédiate.
|
||||
#[test]
|
||||
fn after_cold_first_turn_subsequent_enqueue_is_immediate() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let pty = Arc::new(FakePty::new());
|
||||
let a = agent(1);
|
||||
let h = handle(22);
|
||||
pty.seed(&h, vec![b"ready\n> ".to_vec()]);
|
||||
let inbox = MediatedInbox::with_pty(
|
||||
Arc::new(InMemoryMailbox::new()),
|
||||
Arc::new(FixedClock(1)),
|
||||
Arc::clone(&pty) as Arc<dyn PtyPort>,
|
||||
)
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
|
||||
// Cellule front montée ⇒ livraison observable via l'événement.
|
||||
inbox.set_front_attached(a, true);
|
||||
inbox.mark_starting(a);
|
||||
inbox.enqueue(a, ticket(10, "first"));
|
||||
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()), SubmitConfig::default());
|
||||
assert!(wait_until(|| bus.delegation_ready().len() == 1));
|
||||
|
||||
// Fin du 1er tour (idea_reply) puis nouvel enqueue ⇒ livraison immédiate.
|
||||
inbox.mark_idle(a);
|
||||
inbox.enqueue(a, ticket(11, "second"));
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(
|
||||
ready.len(),
|
||||
2,
|
||||
"second tour livré immédiatement (plus de gate)"
|
||||
);
|
||||
assert!(ready[1].1.ends_with("\nsecond"));
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Lot 2 — détection de stagnation (last_seen / sweep_stalled / liveness)
|
||||
// ====================================================================
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use domain::input::AgentLiveness;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Horloge millis **mutable** (atomique) pour piloter le temps dans les tests de
|
||||
/// stagnation sans horloge réelle.
|
||||
@ -1211,7 +1759,11 @@ mod tests {
|
||||
// Dans la fenêtre : pas de transition.
|
||||
clock.set(1_000 + 30_000);
|
||||
inbox.sweep_stalled();
|
||||
assert_eq!(bus.liveness_events(), vec![], "à la limite exacte, pas encore stalled");
|
||||
assert_eq!(
|
||||
bus.liveness_events(),
|
||||
vec![],
|
||||
"à la limite exacte, pas encore stalled"
|
||||
);
|
||||
|
||||
// Au-delà du seuil : exactement une transition Stalled.
|
||||
clock.set(1_000 + 30_001);
|
||||
|
||||
@ -24,6 +24,7 @@ pub mod input;
|
||||
pub mod inspector;
|
||||
pub mod mailbox;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod process;
|
||||
pub mod pty;
|
||||
pub mod remote;
|
||||
@ -49,6 +50,7 @@ pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
REQUESTS_SUBDIR,
|
||||
};
|
||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||
pub use process::LocalProcessSpawner;
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use remote::{remote_host, LocalHost};
|
||||
@ -61,8 +63,8 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
||||
pub use store::{
|
||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder,
|
||||
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||
VECTOR_ONNX_ENABLED,
|
||||
FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
||||
FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
|
||||
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
362
crates/infrastructure/src/permission/claude.rs
Normal file
362
crates/infrastructure/src/permission/claude.rs
Normal file
@ -0,0 +1,362 @@
|
||||
//! Claude Code permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the `.claude/settings.local.json` seed written into an agent run dir:
|
||||
//! full project autonomy (`bypassPermissions` + broad Read/Edit/Write/Bash) with
|
||||
//! the project root granted as an additional working directory, while keeping
|
||||
//! destructive/out-of-project commands denied. The translation is extracted
|
||||
//! verbatim from the former `claude_settings_seed` in `lifecycle.rs`.
|
||||
|
||||
use domain::permission::{
|
||||
Capability, CommandMatcher, Effect, EffectivePermissions, PermissionProjection,
|
||||
PermissionProjector, PermissionRule, Posture, ProjectedFile, ProjectionContext, ProjectorKey,
|
||||
};
|
||||
|
||||
use super::json_escape;
|
||||
|
||||
/// Run-dir-relative path of the owned Claude settings seed.
|
||||
const SETTINGS_REL_PATH: &str = ".claude/settings.local.json";
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Claude Code's `settings.local.json`.
|
||||
///
|
||||
/// Pure: `project` only computes the JSON; the launch path materialises it. A
|
||||
/// `Replace`-owned file (clobbered at launch, removed on swap-away).
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct ClaudePermissionProjector;
|
||||
|
||||
impl PermissionProjector for ClaudePermissionProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Claude
|
||||
}
|
||||
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected (native prompting).
|
||||
let Some(_) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let contents = claude_settings_seed(ctx.project_root, eff);
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::Replace {
|
||||
rel_path: SETTINGS_REL_PATH.to_owned(),
|
||||
contents,
|
||||
}],
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
vec![SETTINGS_REL_PATH.to_owned()]
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Claude Code permission seed. `project_root` is embedded verbatim
|
||||
/// (JSON-escaped) and granted as an additional working directory, since the cwd is
|
||||
/// the run dir and the agent works on the root above it.
|
||||
fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermissions>) -> String {
|
||||
let root = json_escape(project_root);
|
||||
let default_mode = match permissions.map(EffectivePermissions::fallback) {
|
||||
Some(Posture::Deny) => "plan",
|
||||
Some(Posture::Ask) => "acceptEdits",
|
||||
Some(Posture::Allow) | None => "bypassPermissions",
|
||||
};
|
||||
let allow = claude_permission_entries(permissions, Effect::Allow);
|
||||
let deny = claude_permission_entries(permissions, Effect::Deny);
|
||||
let default_allow = [
|
||||
"Read".to_owned(),
|
||||
"Edit".to_owned(),
|
||||
"Write".to_owned(),
|
||||
"Bash".to_owned(),
|
||||
];
|
||||
let allow = json_string_array(if allow.is_empty() {
|
||||
&default_allow
|
||||
} else {
|
||||
&allow
|
||||
});
|
||||
let deny = json_string_array(&merge_default_deny(deny));
|
||||
format!(
|
||||
r#"{{
|
||||
"permissions": {{
|
||||
"defaultMode": "{default_mode}",
|
||||
"additionalDirectories": [
|
||||
"{root}"
|
||||
],
|
||||
"allow": {allow},
|
||||
"deny": {deny}
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_default_deny(mut deny: Vec<String>) -> Vec<String> {
|
||||
for item in [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
if !deny.iter().any(|existing| existing == item) {
|
||||
deny.push(item.to_owned());
|
||||
}
|
||||
}
|
||||
deny
|
||||
}
|
||||
|
||||
fn claude_permission_entries(
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
effect: Effect,
|
||||
) -> Vec<String> {
|
||||
let Some(permissions) = permissions else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for rule in permissions.rules() {
|
||||
if rule.effect() != effect {
|
||||
continue;
|
||||
}
|
||||
match rule.capability() {
|
||||
Capability::Read => push_path_entries(&mut out, "Read", rule),
|
||||
Capability::Write => {
|
||||
push_path_entries(&mut out, "Edit", rule);
|
||||
push_path_entries(&mut out, "Write", rule);
|
||||
}
|
||||
Capability::Delete => push_delete_entries(&mut out, rule),
|
||||
Capability::ExecuteBash => push_bash_entries(&mut out, rule),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push_path_entries(out: &mut Vec<String>, capability: &str, rule: &PermissionRule) {
|
||||
if rule.paths().is_empty() {
|
||||
out.push(capability.to_owned());
|
||||
return;
|
||||
}
|
||||
for glob in rule.paths().globs() {
|
||||
out.push(format!("{capability}({})", glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_delete_entries(out: &mut Vec<String>, rule: &PermissionRule) {
|
||||
if rule.paths().is_empty() {
|
||||
out.push("Bash(rm *)".to_owned());
|
||||
return;
|
||||
}
|
||||
for glob in rule.paths().globs() {
|
||||
out.push(format!("Bash(rm {})", glob.pattern()));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_bash_entries(out: &mut Vec<String>, rule: &PermissionRule) {
|
||||
if rule.commands().is_empty() {
|
||||
out.push("Bash".to_owned());
|
||||
return;
|
||||
}
|
||||
for cmd in rule.commands() {
|
||||
if cmd.effect != rule.effect() {
|
||||
continue;
|
||||
}
|
||||
out.push(format!("Bash({})", command_matcher_pattern(&cmd.matcher)));
|
||||
}
|
||||
}
|
||||
|
||||
fn command_matcher_pattern(matcher: &CommandMatcher) -> String {
|
||||
match matcher {
|
||||
CommandMatcher::Exact(value) => value.clone(),
|
||||
CommandMatcher::Prefix(value) => format!("{value}*"),
|
||||
CommandMatcher::Glob(glob) => glob.pattern().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_string_array(items: &[String]) -> String {
|
||||
if items.is_empty() {
|
||||
return "[]".to_owned();
|
||||
}
|
||||
let body = items
|
||||
.iter()
|
||||
.map(|item| format!(" \"{}\"", json_escape(item)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n");
|
||||
format!("[\n{body}\n ]")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::permission::{resolve, PathScope, PermissionSet};
|
||||
use serde_json::Value;
|
||||
|
||||
fn ctx<'a>(root: &'a str, run_dir: &'a str) -> ProjectionContext<'a> {
|
||||
ProjectionContext {
|
||||
project_root: root,
|
||||
run_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_scope(patterns: &[&str]) -> PathScope {
|
||||
PathScope::new(patterns.iter().map(ToString::to_string)).unwrap()
|
||||
}
|
||||
|
||||
/// Builds an [`EffectivePermissions`] from a single (project) set via the
|
||||
/// domain API, exactly like the `permission` unit tests do.
|
||||
fn eff_with(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
|
||||
resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap()
|
||||
}
|
||||
|
||||
/// Projects and returns the parsed `settings.local.json` value, asserting the
|
||||
/// projection's structural contract (1 Replace file, no args/env) along the way.
|
||||
fn project_json(eff: &EffectivePermissions, root: &str) -> Value {
|
||||
let proj = ClaudePermissionProjector.project(Some(eff), &ctx(root, "/run/agent"));
|
||||
assert!(proj.args.is_empty(), "Claude projection carries no args");
|
||||
assert!(proj.env.is_empty(), "Claude projection carries no env");
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::Replace { rel_path, contents } => {
|
||||
assert_eq!(rel_path, SETTINGS_REL_PATH);
|
||||
serde_json::from_str(contents).expect("the produced settings is valid JSON")
|
||||
}
|
||||
ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"),
|
||||
}
|
||||
}
|
||||
|
||||
fn str_array(value: &Value) -> Vec<String> {
|
||||
value
|
||||
.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.map(|v| v.as_str().expect("string").to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---- product invariant + ownership ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = ClaudePermissionProjector.project(None, &ctx("/proj", "/run"));
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_the_settings_file() {
|
||||
assert_eq!(
|
||||
ClaudePermissionProjector.owned_replace_paths(),
|
||||
vec![SETTINGS_REL_PATH.to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (1) posture → defaultMode --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn default_mode_maps_each_posture() {
|
||||
for (posture, mode) in [
|
||||
(Posture::Allow, "bypassPermissions"),
|
||||
(Posture::Ask, "acceptEdits"),
|
||||
(Posture::Deny, "plan"),
|
||||
] {
|
||||
let json = project_json(&eff_with(vec![], posture), "/proj");
|
||||
assert_eq!(
|
||||
json["permissions"]["defaultMode"], mode,
|
||||
"posture {posture:?} should map to defaultMode {mode}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (2) deny-wins: deny entry surfaces in the deny list -------------
|
||||
|
||||
#[test]
|
||||
fn specific_deny_with_broad_allow_appears_in_deny_list() {
|
||||
let rules = vec![
|
||||
PermissionRule::file(Capability::Write, Effect::Deny, path_scope(&[".ideai/**"]))
|
||||
.unwrap(),
|
||||
PermissionRule::file(Capability::Write, Effect::Allow, path_scope(&["**"])).unwrap(),
|
||||
];
|
||||
let json = project_json(&eff_with(rules, Posture::Allow), "/proj");
|
||||
|
||||
let deny = str_array(&json["permissions"]["deny"]);
|
||||
// A Write capability fans out to both Edit(..) and Write(..) entries.
|
||||
assert!(deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}");
|
||||
assert!(deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}");
|
||||
|
||||
let allow = str_array(&json["permissions"]["allow"]);
|
||||
assert!(
|
||||
allow.contains(&"Edit(**)".to_owned()) && allow.contains(&"Write(**)".to_owned()),
|
||||
"the broad allow stays in the allow list; allow={allow:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (3) additionalDirectories carries the (escaped) project root ----
|
||||
|
||||
#[test]
|
||||
fn additional_directories_contains_project_root_escaped() {
|
||||
// A Windows-ish path with a backslash AND a quote exercises JSON escaping;
|
||||
// parsing it back must yield the original raw path verbatim.
|
||||
let root = r#"C:\Users\a"b\proj"#;
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), root);
|
||||
let dirs = str_array(&json["permissions"]["additionalDirectories"]);
|
||||
assert_eq!(dirs, vec![root.to_owned()]);
|
||||
}
|
||||
|
||||
// ---- (4) hard-coded destructive guardrails --------------------------
|
||||
|
||||
#[test]
|
||||
fn default_deny_guardrails_are_present() {
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
|
||||
let deny = str_array(&json["permissions"]["deny"]);
|
||||
for guard in [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (5) valid JSON + expected static shape -------------------------
|
||||
|
||||
#[test]
|
||||
fn produced_settings_has_expected_static_shape() {
|
||||
// `project_json` already proved the document parses; assert the fixed keys.
|
||||
let json = project_json(&eff_with(vec![], Posture::Ask), "/proj");
|
||||
assert_eq!(json["enabledMcpjsonServers"][0], "idea");
|
||||
assert_eq!(json["skipDangerousModePermissionPrompt"], true);
|
||||
assert_eq!(json["sandbox"]["enabled"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_rules_fall_back_to_broad_default_allow() {
|
||||
let json = project_json(&eff_with(vec![], Posture::Allow), "/proj");
|
||||
let allow = str_array(&json["permissions"]["allow"]);
|
||||
assert_eq!(
|
||||
allow,
|
||||
vec![
|
||||
"Read".to_owned(),
|
||||
"Edit".to_owned(),
|
||||
"Write".to_owned(),
|
||||
"Bash".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
190
crates/infrastructure/src/permission/codex.rs
Normal file
190
crates/infrastructure/src/permission/codex.rs
Normal file
@ -0,0 +1,190 @@
|
||||
//! Codex CLI permission projector (lot LP3-2).
|
||||
//!
|
||||
//! Produces the **permission-relevant** part of Codex's `config.toml`
|
||||
//! (`sandbox_mode` / `approval_policy`) plus the matching launch args
|
||||
//! (`--sandbox` / `--ask-for-approval`). The posture→mode derivation is extracted
|
||||
//! verbatim from the former `codex_sandbox_mode` / `codex_approval_policy` /
|
||||
//! `apply_codex_cli_permission_args` in `lifecycle.rs`.
|
||||
//!
|
||||
//! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the
|
||||
//! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust
|
||||
//! concerns, not permissions). The projector therefore emits a
|
||||
//! [`ProjectedFile::MergeToml`] limited to the two permission keys it manages —
|
||||
//! everything else in the file is preserved by the fold, and the file is **never**
|
||||
//! deleted on swap (hence an empty `owned_replace_paths`).
|
||||
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
|
||||
use super::toml_string;
|
||||
|
||||
/// Run-dir-relative path of Codex's `config.toml`. Codex reads its config from
|
||||
/// `$CODEX_HOME/config.toml`; IdeA isolates `CODEX_HOME` to `{runDir}/.codex`, so
|
||||
/// the file lives at `.codex/config.toml` relative to the run dir.
|
||||
const CONFIG_REL_PATH: &str = ".codex/config.toml";
|
||||
|
||||
/// The two top-level keys this projector manages in `config.toml`. Everything else
|
||||
/// (MCP table, trust entries, user keys) is preserved by the merge.
|
||||
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
|
||||
|
||||
/// Projects [`EffectivePermissions`] into Codex's sandbox/approval config + args.
|
||||
///
|
||||
/// Pure: `project` only computes the plan; the launch path merges the TOML fragment
|
||||
/// and appends the args.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct CodexPermissionProjector;
|
||||
|
||||
impl PermissionProjector for CodexPermissionProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Codex
|
||||
}
|
||||
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||
// native sandbox/approval defaults (no args, no managed keys written).
|
||||
let Some(permissions) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let sandbox = codex_sandbox_mode(permissions);
|
||||
let approval = codex_approval_policy(permissions);
|
||||
|
||||
// Permission-only TOML fragment (escaped exactly like the former
|
||||
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
|
||||
// permission concern and stay with the MCP wiring (LP3-3).
|
||||
let contents = format!(
|
||||
"sandbox_mode = {}\napproval_policy = {}\n",
|
||||
toml_string(sandbox),
|
||||
toml_string(approval),
|
||||
);
|
||||
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
|
||||
contents,
|
||||
}],
|
||||
args: vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
// config.toml is co-owned (MergeToml), never an owned Replace file.
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_sandbox_mode(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Deny => "read-only",
|
||||
Posture::Ask | Posture::Allow => "workspace-write",
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_approval_policy(permissions: &EffectivePermissions) -> &'static str {
|
||||
match permissions.fallback() {
|
||||
Posture::Allow => "never",
|
||||
Posture::Ask | Posture::Deny => "on-request",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::permission::{resolve, PermissionSet};
|
||||
|
||||
fn ctx<'a>() -> ProjectionContext<'a> {
|
||||
ProjectionContext {
|
||||
project_root: "/proj",
|
||||
run_dir: "/run/agent",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an [`EffectivePermissions`] with the given fallback posture via the
|
||||
/// domain API (only the fallback drives Codex's sandbox/approval derivation).
|
||||
fn eff(fallback: Posture) -> EffectivePermissions {
|
||||
resolve(Some(&PermissionSet::new(vec![], fallback)), None).unwrap()
|
||||
}
|
||||
|
||||
// ---- product invariant + ownership ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn project_none_is_empty() {
|
||||
let proj = CodexPermissionProjector.project(None, &ctx());
|
||||
assert!(proj.files.is_empty());
|
||||
assert!(proj.args.is_empty());
|
||||
assert!(proj.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_replace_paths_is_empty() {
|
||||
// config.toml is co-owned (MergeToml) ⇒ nothing to clean up on swap.
|
||||
assert!(CodexPermissionProjector.owned_replace_paths().is_empty());
|
||||
}
|
||||
|
||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||
// coherence + MergeToml shape -------------------------------
|
||||
|
||||
#[test]
|
||||
fn posture_maps_sandbox_and_approval_in_file_and_args() {
|
||||
for (posture, sandbox, approval) in [
|
||||
(Posture::Deny, "read-only", "on-request"),
|
||||
(Posture::Ask, "workspace-write", "on-request"),
|
||||
(Posture::Allow, "workspace-write", "never"),
|
||||
] {
|
||||
let proj = CodexPermissionProjector.project(Some(&eff(posture)), &ctx());
|
||||
assert!(proj.env.is_empty(), "Codex projection carries no env");
|
||||
|
||||
// -- The single MergeToml file, with the two managed permission keys.
|
||||
assert_eq!(proj.files.len(), 1, "exactly one file projected");
|
||||
match &proj.files[0] {
|
||||
ProjectedFile::MergeToml {
|
||||
rel_path,
|
||||
managed_tables,
|
||||
managed_keys,
|
||||
contents,
|
||||
} => {
|
||||
assert_eq!(rel_path, CONFIG_REL_PATH);
|
||||
assert!(managed_tables.is_empty(), "no managed tables");
|
||||
assert_eq!(
|
||||
managed_keys,
|
||||
&vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()]
|
||||
);
|
||||
assert!(
|
||||
contents.contains(&format!("sandbox_mode = \"{sandbox}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
assert!(
|
||||
contents.contains(&format!("approval_policy = \"{approval}\"")),
|
||||
"posture {posture:?}: contents={contents:?}"
|
||||
);
|
||||
}
|
||||
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
|
||||
}
|
||||
|
||||
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
|
||||
assert_eq!(
|
||||
proj.args,
|
||||
vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
],
|
||||
"posture {posture:?}: args must mirror the TOML values"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
crates/infrastructure/src/permission/mod.rs
Normal file
46
crates/infrastructure/src/permission/mod.rs
Normal file
@ -0,0 +1,46 @@
|
||||
//! Per-CLI **permission projectors** (lot LP3-2).
|
||||
//!
|
||||
//! Concrete implementations of the domain port
|
||||
//! [`domain::permission::PermissionProjector`]: they translate the resolved
|
||||
//! [`domain::permission::EffectivePermissions`] into a CLI-specific
|
||||
//! [`domain::permission::PermissionProjection`] — a **plan** (files + args + env),
|
||||
//! never an action. Writing/merging the plan into the agent run dir is the launch
|
||||
//! path's job (lot LP3-3); the projectors here stay **pure** (no `FileSystem`, no
|
||||
//! I/O), exactly like the domain trait demands.
|
||||
//!
|
||||
//! This is an **extraction**: the translation rules (postures → allow/deny/ask
|
||||
//! lists for Claude, posture → sandbox/approval modes for Codex) are moved here
|
||||
//! verbatim from `application/src/agent/lifecycle.rs`, only reshaped to return a
|
||||
//! `PermissionProjection`. The rules themselves are unchanged.
|
||||
|
||||
mod claude;
|
||||
mod codex;
|
||||
|
||||
pub use claude::ClaudePermissionProjector;
|
||||
pub use codex::CodexPermissionProjector;
|
||||
|
||||
/// Minimal JSON string escaper for embedding a filesystem path / permission entry
|
||||
/// in a settings document (handles the characters that actually occur in paths:
|
||||
/// backslash, quote, control chars). Extracted verbatim from `lifecycle.rs`.
|
||||
pub(crate) fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Escapes `s` as a TOML basic string (quotes included). The escape set required
|
||||
/// by a TOML basic string coincides with JSON's for the characters that concern
|
||||
/// us (paths, mode keywords). Extracted verbatim from `lifecycle.rs`.
|
||||
pub(crate) fn toml_string(s: &str) -> String {
|
||||
format!("\"{}\"", json_escape(s))
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
mod context;
|
||||
mod embedder;
|
||||
mod memory;
|
||||
mod permission;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod skill;
|
||||
@ -24,6 +25,7 @@ pub use embedder::{
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||
pub use permission::FsPermissionStore;
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
pub use skill::FsSkillStore;
|
||||
|
||||
65
crates/infrastructure/src/store/permission.rs
Normal file
65
crates/infrastructure/src/store/permission.rs
Normal file
@ -0,0 +1,65 @@
|
||||
//! Filesystem-backed [`PermissionStore`] for project agent permissions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{FileSystem, FsError, PermissionStore, RemotePath, StoreError};
|
||||
use domain::{Project, ProjectPermissions};
|
||||
|
||||
const PERMISSIONS_FILE: &str = "permissions.json";
|
||||
|
||||
/// JSON-file implementation for `<project>/.ideai/permissions.json`.
|
||||
#[derive(Clone)]
|
||||
pub struct FsPermissionStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl FsPermissionStore {
|
||||
/// Builds the store from an injected filesystem port.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
fn path(project: &Project) -> RemotePath {
|
||||
let root = project.root.as_str().trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{root}/.ideai/{PERMISSIONS_FILE}"))
|
||||
}
|
||||
|
||||
async fn ensure_ideai(&self, project: &Project) -> Result<(), StoreError> {
|
||||
let root = project.root.as_str().trim_end_matches(['/', '\\']);
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(format!("{root}/.ideai")))
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PermissionStore for FsPermissionStore {
|
||||
async fn load_permissions(&self, project: &Project) -> Result<ProjectPermissions, StoreError> {
|
||||
match self.fs.read(&Self::path(project)).await {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
Err(FsError::NotFound(_)) => Ok(ProjectPermissions::default()),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
project: &Project,
|
||||
permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ensure_ideai(project).await?;
|
||||
let mut bytes = serde_json::to_vec_pretty(permissions)
|
||||
.map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
bytes.push(b'\n');
|
||||
self.fs
|
||||
.write(&Self::path(project), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
}
|
||||
@ -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