Étend l'enforcement OS au chemin structuré (sessions Claude/Codex mode JSON), jusqu'ici seulement advisory. Approche validée par l'Architecte : transposer la technique du PTY plutôt qu'un pre_exec (rejeté — landlock alloue, deadlock malloc post-fork en process multithreadé). Mécanique (cfg(target_os=linux)) : run_turn_sandboxed/drain_sandboxed exécutent enforce(plan) sur un thread jetable AVANT le spawn std, puis std::process::spawn depuis ce thread ; l'enfant hérite le domaine Landlock via les credentials de la tâche (garanti à travers fork/clone/execve, y compris posix_spawn — pas de pre_exec nécessaire, forbid(unsafe_code) préservé). Fail-closed sur Err d'enforce (aucun child). Timeout sous sandbox : oneshot killer + tokio::time::timeout → kill → EOF → reap (pas de zombie/thread bloqué). Chemin non-sandboxé (plan None / pas d'enforcer / non-Linux) = drain async tokio inchangé. Contrat : SpawnLine.sandbox ; AgentSessionFactory::start(.., sandbox) ; StructuredSessionFactory::with_sandbox_enforcer (jumeau du PTY) ; plan calculé en step 5d de lifecycle relayé à launch_structured ; default_enforcer() injecté au composition root. Tests : 7 invariants e2e (parité, companion négatif, fail-closed, no-op natif, confinement de l'irréversibilité entre tours, timeout, resume préservé) — zéro token (sh/FakeCli). 80 suites vertes, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
403 lines
13 KiB
Rust
403 lines
13 KiB
Rust
//! LOT D0 (fondation §17.1) — tests purs du domaine :
|
|
//! - `StructuredAdapter` : round-trip serde camelCase (`"claude"` / `"codex"`) ;
|
|
//! - `AgentProfile.structured_adapter` : zéro régression serde (omission via
|
|
//! `skip_serializing_if`, défaut `None` à la désérialisation), round-trip avec
|
|
//! adapter, et builder `with_structured_adapter` qui ne touche rien d'autre ;
|
|
//! - types du port `AgentSession` : `ReplyEvent` (3 variantes + égalité),
|
|
//! `AgentSessionError` (variantes + `Display`/`Error`), et un **fake in-module**
|
|
//! prouvant que `AgentSession`/`AgentSessionFactory` sont implémentables (D0 = pas
|
|
//! d'impl réelle, seulement la conformité de signature).
|
|
//!
|
|
//! Style calqué sur `serde_roundtrip.rs` / `agent_profile_a0.rs` (réutilise les
|
|
//! constructeurs validants du domaine, pas de littéraux fragiles).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use domain::ids::{ProfileId, SessionId};
|
|
use domain::ports::{
|
|
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, ReplyEvent, ReplyStream,
|
|
SessionPlan,
|
|
};
|
|
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
|
use domain::project::ProjectPath;
|
|
use domain::MarkdownDoc;
|
|
use uuid::Uuid;
|
|
|
|
fn profid(n: u128) -> ProfileId {
|
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn roundtrip<T>(value: &T) -> T
|
|
where
|
|
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
|
|
{
|
|
let json = serde_json::to_string(value).expect("serialize");
|
|
serde_json::from_str(&json).expect("deserialize")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// StructuredAdapter — round-trip serde camelCase ("claude" / "codex")
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn structured_adapter_serialises_camel_case() {
|
|
assert_eq!(
|
|
serde_json::to_string(&StructuredAdapter::Claude).unwrap(),
|
|
"\"claude\""
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_string(&StructuredAdapter::Codex).unwrap(),
|
|
"\"codex\""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn structured_adapter_deserialises_from_camel_case() {
|
|
let claude: StructuredAdapter = serde_json::from_str("\"claude\"").unwrap();
|
|
let codex: StructuredAdapter = serde_json::from_str("\"codex\"").unwrap();
|
|
assert_eq!(claude, StructuredAdapter::Claude);
|
|
assert_eq!(codex, StructuredAdapter::Codex);
|
|
}
|
|
|
|
#[test]
|
|
fn structured_adapter_roundtrips_both_variants() {
|
|
for adapter in [StructuredAdapter::Claude, StructuredAdapter::Codex] {
|
|
assert_eq!(roundtrip(&adapter), adapter);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn structured_adapter_rejects_unknown_variant() {
|
|
// Un JSON hors vocabulaire ne doit pas se faufiler (pas de défaut silencieux).
|
|
let parsed: Result<StructuredAdapter, _> = serde_json::from_str("\"gemini\"");
|
|
assert!(parsed.is_err(), "unknown adapter must fail to deserialise");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AgentProfile.structured_adapter — zéro régression serde
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Profil minimal TUI/PTY (sans adapter), construit via le constructeur validant.
|
|
fn pty_profile() -> AgentProfile {
|
|
AgentProfile::new(
|
|
profid(1),
|
|
"Gemini CLI",
|
|
"gemini",
|
|
vec![],
|
|
ContextInjection::convention_file("GEMINI.md").unwrap(),
|
|
Some("gemini --version".to_owned()),
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn profile_without_adapter_omits_the_field() {
|
|
// skip_serializing_if = Option::is_none ⇒ la clé `structuredAdapter` est ABSENTE.
|
|
let p = pty_profile();
|
|
assert_eq!(p.structured_adapter, None);
|
|
let json = serde_json::to_string(&p).unwrap();
|
|
assert!(
|
|
!json.contains("structuredAdapter"),
|
|
"field must be omitted when None; json was {json}"
|
|
);
|
|
// Et le round-trip reste fidèle.
|
|
assert_eq!(roundtrip(&p), p);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_profile_without_adapter_deserialises_to_none() {
|
|
// Un `profiles.json` produit AVANT que le champ existe : pas de clé
|
|
// `structuredAdapter` ⇒ défaut `None` (zéro régression de désérialisation).
|
|
let json = r#"{
|
|
"id": "00000000-0000-0000-0000-000000000001",
|
|
"name": "Gemini CLI",
|
|
"command": "gemini",
|
|
"args": [],
|
|
"contextInjection": { "strategy": "conventionFile", "target": "GEMINI.md" },
|
|
"detect": null,
|
|
"cwdTemplate": "{agentRunDir}"
|
|
}"#;
|
|
let p: AgentProfile = serde_json::from_str(json).expect("legacy profile must deserialise");
|
|
assert_eq!(p.structured_adapter, None);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_with_adapter_roundtrips_and_uses_camel_case() {
|
|
let p = pty_profile().with_structured_adapter(StructuredAdapter::Claude);
|
|
let json = serde_json::to_string(&p).unwrap();
|
|
// Clé camelCase + valeur camelCase de la variante.
|
|
assert!(
|
|
json.contains("\"structuredAdapter\":\"claude\""),
|
|
"json was {json}"
|
|
);
|
|
assert!(!json.contains("structured_adapter"), "json was {json}");
|
|
assert_eq!(roundtrip(&p), p);
|
|
|
|
// Codex aussi.
|
|
let c = pty_profile().with_structured_adapter(StructuredAdapter::Codex);
|
|
let json = serde_json::to_string(&c).unwrap();
|
|
assert!(
|
|
json.contains("\"structuredAdapter\":\"codex\""),
|
|
"json was {json}"
|
|
);
|
|
assert_eq!(roundtrip(&c), c);
|
|
}
|
|
|
|
#[test]
|
|
fn with_structured_adapter_sets_only_that_field() {
|
|
let before = pty_profile();
|
|
let after = before
|
|
.clone()
|
|
.with_structured_adapter(StructuredAdapter::Codex);
|
|
|
|
// Le seul champ muté :
|
|
assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex));
|
|
assert_ne!(after.structured_adapter, before.structured_adapter);
|
|
|
|
// Tous les autres champs strictement inchangés :
|
|
assert_eq!(after.id, before.id);
|
|
assert_eq!(after.name, before.name);
|
|
assert_eq!(after.command, before.command);
|
|
assert_eq!(after.args, before.args);
|
|
assert_eq!(after.context_injection, before.context_injection);
|
|
assert_eq!(after.detect, before.detect);
|
|
assert_eq!(after.cwd_template, before.cwd_template);
|
|
assert_eq!(after.session, before.session);
|
|
|
|
// Preuve d'équivalence : ne diffère du `before` que par l'adapter posé.
|
|
let mut patched = before;
|
|
patched.structured_adapter = Some(StructuredAdapter::Codex);
|
|
assert_eq!(after, patched);
|
|
}
|
|
|
|
#[test]
|
|
fn with_structured_adapter_is_last_write_wins() {
|
|
// Re-poser un adapter écrase le précédent (idempotent par valeur).
|
|
let p = pty_profile()
|
|
.with_structured_adapter(StructuredAdapter::Claude)
|
|
.with_structured_adapter(StructuredAdapter::Codex);
|
|
assert_eq!(p.structured_adapter, Some(StructuredAdapter::Codex));
|
|
}
|
|
|
|
#[test]
|
|
fn new_defaults_structured_adapter_to_none() {
|
|
// Le constructeur `new` reste stable : il ne pose jamais d'adapter.
|
|
assert_eq!(pty_profile().structured_adapter, None);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ReplyEvent — 3 variantes construites, égalité, clone
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn reply_event_three_variants_construct_and_carry_payload() {
|
|
let delta = ReplyEvent::TextDelta {
|
|
text: "hel".to_owned(),
|
|
};
|
|
let tool = ReplyEvent::ToolActivity {
|
|
label: "reads a file".to_owned(),
|
|
};
|
|
let final_ = ReplyEvent::Final {
|
|
content: "hello world".to_owned(),
|
|
};
|
|
|
|
match &delta {
|
|
ReplyEvent::TextDelta { text } => assert_eq!(text, "hel"),
|
|
other => panic!("expected TextDelta, got {other:?}"),
|
|
}
|
|
match &tool {
|
|
ReplyEvent::ToolActivity { label } => assert_eq!(label, "reads a file"),
|
|
other => panic!("expected ToolActivity, got {other:?}"),
|
|
}
|
|
match &final_ {
|
|
ReplyEvent::Final { content } => assert_eq!(content, "hello world"),
|
|
other => panic!("expected Final, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn reply_event_equality_and_clone() {
|
|
let e = ReplyEvent::Final {
|
|
content: "done".to_owned(),
|
|
};
|
|
assert_eq!(e.clone(), e);
|
|
|
|
// Même variante, payload différent ⇒ inégal.
|
|
assert_ne!(
|
|
e,
|
|
ReplyEvent::Final {
|
|
content: "other".to_owned()
|
|
}
|
|
);
|
|
// Variantes différentes ⇒ inégal.
|
|
assert_ne!(
|
|
ReplyEvent::TextDelta { text: "x".into() },
|
|
ReplyEvent::ToolActivity { label: "x".into() }
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AgentSessionError — variantes + Display / std::error::Error
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn agent_session_error_display_messages() {
|
|
assert_eq!(
|
|
AgentSessionError::Start("cli missing".to_owned()).to_string(),
|
|
"agent session start failed: cli missing"
|
|
);
|
|
assert_eq!(
|
|
AgentSessionError::Io("broken pipe".to_owned()).to_string(),
|
|
"agent session io failed: broken pipe"
|
|
);
|
|
assert_eq!(
|
|
AgentSessionError::Decode("bad json".to_owned()).to_string(),
|
|
"agent session decode failed: bad json"
|
|
);
|
|
assert_eq!(
|
|
AgentSessionError::Timeout.to_string(),
|
|
"agent session reply timed out"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_session_error_is_std_error_and_equates() {
|
|
// Conformité au trait std::error::Error (thiserror).
|
|
fn assert_error<E: std::error::Error>(_e: &E) {}
|
|
let e = AgentSessionError::Decode("x".to_owned());
|
|
assert_error(&e);
|
|
|
|
// Clone + égalité par variante/payload.
|
|
assert_eq!(e.clone(), e);
|
|
assert_ne!(
|
|
AgentSessionError::Start("a".into()),
|
|
AgentSessionError::Start("b".into())
|
|
);
|
|
assert_ne!(
|
|
AgentSessionError::Timeout,
|
|
AgentSessionError::Io("t".into())
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AgentSession / AgentSessionFactory — fake in-module (conformité de signature)
|
|
//
|
|
// D0 ne fournit PAS d'impl réelle : ce fake prouve seulement que les traits sont
|
|
// implémentables (object-safe, signatures cohérentes). Aucune logique réelle.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct FakeSession {
|
|
id: SessionId,
|
|
conversation_id: Option<String>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSession for FakeSession {
|
|
fn id(&self) -> SessionId {
|
|
self.id
|
|
}
|
|
|
|
fn conversation_id(&self) -> Option<String> {
|
|
self.conversation_id.clone()
|
|
}
|
|
|
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
// Flux borné minimal : un delta puis le Final déterministe.
|
|
let stream: ReplyStream = Box::new(
|
|
vec![
|
|
ReplyEvent::TextDelta {
|
|
text: prompt.to_owned(),
|
|
},
|
|
ReplyEvent::Final {
|
|
content: prompt.to_owned(),
|
|
},
|
|
]
|
|
.into_iter(),
|
|
);
|
|
Ok(stream)
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct FakeFactory;
|
|
|
|
#[async_trait]
|
|
impl AgentSessionFactory for FakeFactory {
|
|
fn supports(&self, profile: &AgentProfile) -> bool {
|
|
profile.structured_adapter.is_some()
|
|
}
|
|
|
|
async fn start(
|
|
&self,
|
|
_profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
_cwd: &ProjectPath,
|
|
_session: &SessionPlan,
|
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
|
Ok(Arc::new(FakeSession {
|
|
id: SessionId::from_uuid(Uuid::from_u128(7)),
|
|
conversation_id: None,
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fake_session_proves_trait_is_implementable() {
|
|
let sid = SessionId::from_uuid(Uuid::from_u128(42));
|
|
let session = FakeSession {
|
|
id: sid,
|
|
conversation_id: Some("conv-1".to_owned()),
|
|
};
|
|
|
|
// Consommé comme trait-objet (object-safety du port via #[async_trait]).
|
|
let dyn_session: Arc<dyn AgentSession> = Arc::new(session);
|
|
assert_eq!(dyn_session.id(), sid);
|
|
assert_eq!(dyn_session.conversation_id(), Some("conv-1".to_owned()));
|
|
|
|
// send -> flux d'événements borné se terminant par Final (contrat §17.1).
|
|
let events: Vec<ReplyEvent> = dyn_session.send("ping").await.unwrap().collect();
|
|
assert_eq!(
|
|
events,
|
|
vec![
|
|
ReplyEvent::TextDelta {
|
|
text: "ping".to_owned()
|
|
},
|
|
ReplyEvent::Final {
|
|
content: "ping".to_owned()
|
|
},
|
|
]
|
|
);
|
|
assert!(matches!(events.last(), Some(ReplyEvent::Final { .. })));
|
|
|
|
dyn_session.shutdown().await.expect("shutdown ok");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
|
let factory: Arc<dyn AgentSessionFactory> = Arc::new(FakeFactory);
|
|
|
|
let structured = pty_profile().with_structured_adapter(StructuredAdapter::Claude);
|
|
let pty = pty_profile();
|
|
assert!(factory.supports(&structured));
|
|
assert!(!factory.supports(&pty));
|
|
|
|
let ctx = PreparedContext {
|
|
content: MarkdownDoc::new("# ctx"),
|
|
relative_path: "CLAUDE.md".to_owned(),
|
|
};
|
|
let cwd = ProjectPath::new("/srv/run").unwrap();
|
|
let session = factory
|
|
.start(&structured, &ctx, &cwd, &SessionPlan::None, None)
|
|
.await
|
|
.expect("factory starts a session");
|
|
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
|
}
|