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

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

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

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

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

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

View File

@ -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

View File

@ -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);

View File

@ -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,
};

View File

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

View 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()
]
);
}
}

View 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"
);
}
}
}

View 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))
}

View File

@ -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;

View 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()))
}
}