feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).
Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -5,10 +5,17 @@
|
||||
//! concurrent readers **or** one exclusive writer per resource; different resources
|
||||
//! are independent (their locks are distinct).
|
||||
//!
|
||||
//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced
|
||||
//! **before** taking any lock: a `who` that is not the orchestrator
|
||||
//! ([`domain::may_write_directly`] returning `false`) is rejected with
|
||||
//! [`GuardError::Forbidden`] — it must *propose* instead.
|
||||
//! ## Pure lock — no authorization here (cadrage « orchestrateur du projet », Alt. A)
|
||||
//!
|
||||
//! This adapter is a **pure serialisation primitive**: `acquire_write` never returns
|
||||
//! [`GuardError::Forbidden`]. The single-writer *authorization* of the global
|
||||
//! [`GuardedResource::ProjectContext`] (« only the orchestrator may write it directly,
|
||||
//! everyone else proposes ») is **not** a concurrency concern and no longer lives in
|
||||
//! the guard. It is decided one layer up, in the `ProposeContext` use case, which
|
||||
//! consults the project's `OrchestratorDesignation` (now that the orchestrator can be
|
||||
//! a *designated agent*, not just the human, the guard — which only sees a
|
||||
//! [`ConversationParty`] — could not make that call coherently anyway). The guard's
|
||||
//! sole job is to make readers/writers of the same resource take turns.
|
||||
//!
|
||||
//! ## Cooperative scope (cadrage §9.5)
|
||||
//!
|
||||
@ -32,9 +39,7 @@ use async_trait::async_trait;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{
|
||||
may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease,
|
||||
};
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource, ReadLease, WriteLease};
|
||||
|
||||
/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource.
|
||||
///
|
||||
@ -85,14 +90,12 @@ impl FileGuard for RwFileGuard {
|
||||
|
||||
async fn acquire_write(
|
||||
&self,
|
||||
who: ConversationParty,
|
||||
_who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<WriteLease, GuardError> {
|
||||
// Single-writer rule for the global project context: refuse *before* taking
|
||||
// any lock so a forbidden writer never blocks readers.
|
||||
if !may_write_directly(who, &res) {
|
||||
return Err(GuardError::Forbidden);
|
||||
}
|
||||
// Pure lock (Alt. A): no authorization here — `ProposeContext` decides who may
|
||||
// write the global context. Serialise behind any in-flight readers/writer of
|
||||
// the same resource, then hand back the exclusive RAII lease.
|
||||
let lock = self.lock_for(&res);
|
||||
let guard = lock.write_owned().await;
|
||||
Ok(WriteLease::new(Box::new(guard)))
|
||||
@ -170,22 +173,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_writing_project_context_is_forbidden() {
|
||||
async fn pure_lock_grants_project_context_write_to_any_party() {
|
||||
// Alt. A: the guard is a pure lock — it no longer enforces the single-writer
|
||||
// *authorization* (that moved into `ProposeContext`). Both the human and an
|
||||
// arbitrary agent obtain a write lease on the project context; `Forbidden` is
|
||||
// never produced at this layer.
|
||||
let guard = RwFileGuard::new();
|
||||
let err = guard
|
||||
assert!(guard
|
||||
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
|
||||
.await
|
||||
.is_ok());
|
||||
assert!(guard
|
||||
.acquire_write(agent_party(1), GuardedResource::ProjectContext)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err, GuardError::Forbidden);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_may_write_project_context() {
|
||||
let guard = RwFileGuard::new();
|
||||
let lease = guard
|
||||
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
|
||||
.await;
|
||||
assert!(lease.is_ok());
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -94,6 +94,10 @@ type HeadlessSink =
|
||||
/// 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;
|
||||
/// Taille maximale d'un fragment de consigne écrit dans le PTY en mode headless.
|
||||
const DELEGATION_WRITE_CHUNK_BYTES: usize = 512;
|
||||
/// Pause entre deux fragments de consigne en mode headless.
|
||||
const DELEGATION_WRITE_CHUNK_DELAY_MS: u64 = 8;
|
||||
|
||||
/// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2).
|
||||
///
|
||||
@ -486,8 +490,10 @@ impl MediatedInbox {
|
||||
// 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.
|
||||
// La consigne est fragmentée : les TUI (Codex surtout) peuvent traiter un
|
||||
// gros write unique comme un paste et garder/perdre la fin au démarrage.
|
||||
std::thread::spawn(move || {
|
||||
let _ = pty.write(&handle, text.as_bytes());
|
||||
let _ = write_delegation_chunks(pty.as_ref(), &handle, &text);
|
||||
if delay > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(delay));
|
||||
}
|
||||
@ -662,6 +668,30 @@ fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String
|
||||
format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}")
|
||||
}
|
||||
|
||||
fn write_delegation_chunks(
|
||||
pty: &dyn PtyPort,
|
||||
handle: &PtyHandle,
|
||||
text: &str,
|
||||
) -> Result<(), domain::ports::PtyError> {
|
||||
let mut start = 0;
|
||||
let mut current_len = 0;
|
||||
|
||||
for (idx, ch) in text.char_indices() {
|
||||
let len = ch.len_utf8();
|
||||
if current_len > 0 && current_len + len > DELEGATION_WRITE_CHUNK_BYTES {
|
||||
pty.write(handle, text[start..idx].as_bytes())?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(
|
||||
DELEGATION_WRITE_CHUNK_DELAY_MS,
|
||||
));
|
||||
start = idx;
|
||||
current_len = 0;
|
||||
}
|
||||
current_len += len;
|
||||
}
|
||||
|
||||
pty.write(handle, text[start..].as_bytes())
|
||||
}
|
||||
|
||||
impl InputMediator for MediatedInbox {
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
let ticket_id = ticket.id;
|
||||
@ -1115,6 +1145,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_agent_chunks_long_delegation_before_submit() {
|
||||
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 task = format!("début {} fin", "x".repeat(1200));
|
||||
inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default());
|
||||
|
||||
inbox.enqueue(a, ticket(10, &task));
|
||||
|
||||
assert!(
|
||||
wait_until(|| pty.writes.lock().unwrap().len() >= 4),
|
||||
"long headless delivery writes multiple chunks followed by submit"
|
||||
);
|
||||
let writes = pty.writes.lock().unwrap().clone();
|
||||
assert_eq!(writes.last().map(Vec::as_slice), Some(b"\r".as_slice()));
|
||||
let delivered = writes[..writes.len() - 1]
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
let delivered = String::from_utf8(delivered).unwrap();
|
||||
assert!(
|
||||
delivered.contains(&task),
|
||||
"chunked writes reconstruct the full task"
|
||||
);
|
||||
assert!(
|
||||
bus.delegation_ready().is_empty(),
|
||||
"headless delivery does not also publish DelegationReady"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
|
||||
let inbox = inbox_at(5);
|
||||
|
||||
@ -57,11 +57,11 @@ pub use orchestrator::{
|
||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||
pub use process::LocalProcessSpawner;
|
||||
pub use pty::PortablePtyAdapter;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
pub use remote::{remote_host, LocalHost};
|
||||
pub use runtime::CliAgentRuntime;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use sandbox::LandlockSandbox;
|
||||
pub use ratelimit::RateLimitParser;
|
||||
pub use sandbox::{default_enforcer, NoopSandbox};
|
||||
pub use scheduler::TokioScheduler;
|
||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||
|
||||
@ -292,8 +292,14 @@ mod tests {
|
||||
|
||||
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:?}");
|
||||
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!(
|
||||
@ -330,7 +336,10 @@ mod tests {
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)",
|
||||
] {
|
||||
assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}");
|
||||
assert!(
|
||||
deny.contains(&guard.to_owned()),
|
||||
"missing guardrail {guard}; deny={deny:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
//!
|
||||
//! 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`.
|
||||
//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the
|
||||
//! project root as an additional writable directory (`--add-dir`). 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
|
||||
@ -44,7 +46,7 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
// Product invariant: nothing posed ⇒ nothing projected. Codex keeps its
|
||||
// native sandbox/approval defaults (no args, no managed keys written).
|
||||
@ -63,6 +65,17 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
toml_string(approval),
|
||||
);
|
||||
|
||||
let mut args = vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
approval.to_owned(),
|
||||
];
|
||||
if sandbox == "workspace-write" && !ctx.project_root.is_empty() {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(ctx.project_root.to_owned());
|
||||
}
|
||||
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: CONFIG_REL_PATH.to_owned(),
|
||||
@ -70,12 +83,7 @@ impl PermissionProjector for CodexPermissionProjector {
|
||||
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(),
|
||||
],
|
||||
args,
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
@ -135,7 +143,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
|
||||
// coherence + MergeToml shape -------------------------------
|
||||
// coherence + add-dir + MergeToml shape ---------------------
|
||||
|
||||
#[test]
|
||||
fn posture_maps_sandbox_and_approval_in_file_and_args() {
|
||||
@ -176,7 +184,7 @@ mod tests {
|
||||
|
||||
// -- (7) Args reflect the SAME values as the TOML, in CLI order.
|
||||
assert_eq!(
|
||||
proj.args,
|
||||
&proj.args[..4],
|
||||
vec![
|
||||
"--sandbox".to_owned(),
|
||||
sandbox.to_owned(),
|
||||
@ -185,6 +193,21 @@ mod tests {
|
||||
],
|
||||
"posture {posture:?}: args must mirror the TOML values"
|
||||
);
|
||||
if sandbox == "workspace-write" {
|
||||
assert!(
|
||||
proj.args
|
||||
.windows(2)
|
||||
.any(|w| w == ["--add-dir".to_owned(), "/proj".to_owned()]),
|
||||
"workspace-write posture must add the project root as writable: {:?}",
|
||||
proj.args
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
!proj.args.contains(&"--add-dir".to_owned()),
|
||||
"read-only posture must not add writable dirs: {:?}",
|
||||
proj.args
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -520,8 +520,10 @@ mod sandbox_e2e_tests {
|
||||
#[tokio::test]
|
||||
async fn pty_spawn_enforces_sandbox_plan_end_to_end() {
|
||||
if !landlock_is_enforced() {
|
||||
eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
|
||||
Landlock not available/enforced on this kernel");
|
||||
eprintln!(
|
||||
"skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
|
||||
Landlock not available/enforced on this kernel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -92,10 +92,7 @@ impl ResetTimeFormat {
|
||||
Self::EpochSeconds => c.parse::<i64>().ok().map(|s| s.saturating_mul(1000)),
|
||||
Self::EpochMillis => c.parse::<i64>().ok(),
|
||||
Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c),
|
||||
Self::RelativeSeconds => c
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|d| now_ms + (d * 1000.0) as i64),
|
||||
Self::RelativeSeconds => c.parse::<f64>().ok().map(|d| now_ms + (d * 1000.0) as i64),
|
||||
Self::RelativeMillis => c.parse::<i64>().ok().map(|d| now_ms + d),
|
||||
Self::WallClock => {
|
||||
let (h, m, s) = timeparse::parse_wall_clock(c)?;
|
||||
@ -153,7 +150,11 @@ impl RateLimitParser {
|
||||
.or_else(|| name.parse::<usize>().ok().and_then(|i| caps.get(i)))?;
|
||||
self.time_format.resolve(raw.as_str(), now_ms)
|
||||
});
|
||||
Some(SessionLimit::new(resets_at_ms, now_ms, RateLimitSource::Pattern))
|
||||
Some(SessionLimit::new(
|
||||
resets_at_ms,
|
||||
now_ms,
|
||||
RateLimitSource::Pattern,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@ -204,7 +205,9 @@ mod tests {
|
||||
#[test]
|
||||
fn detect_match_without_reset_capture_has_no_time() {
|
||||
let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap();
|
||||
let limit = parser.detect("oops: rate limit reached!", NOW).expect("détecté");
|
||||
let limit = parser
|
||||
.detect("oops: rate limit reached!", NOW)
|
||||
.expect("détecté");
|
||||
assert_eq!(limit.resets_at_ms, None);
|
||||
assert_eq!(limit.detected_at_ms, NOW);
|
||||
assert_eq!(limit.source, RateLimitSource::Pattern);
|
||||
@ -340,9 +343,13 @@ mod tests {
|
||||
Some("epoch_s"),
|
||||
))
|
||||
.unwrap();
|
||||
let a = parser.detect("resets at 1700000000", NOW).expect("1er détecté");
|
||||
let a = parser
|
||||
.detect("resets at 1700000000", NOW)
|
||||
.expect("1er détecté");
|
||||
assert!(parser.detect("rien ici", NOW).is_none());
|
||||
let b = parser.detect("resets at 1700000000", NOW).expect("2e détecté");
|
||||
let b = parser
|
||||
.detect("resets at 1700000000", NOW)
|
||||
.expect("2e détecté");
|
||||
assert_eq!(a.resets_at_ms, b.resets_at_ms);
|
||||
assert_eq!(a.resets_at_ms, Some(1_700_000_000_000));
|
||||
}
|
||||
@ -368,7 +375,10 @@ mod tests {
|
||||
let profile = base_profile()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_rate_limit_pattern(pattern("rate limit", None, None));
|
||||
assert!(!applies(&profile), "un agent structuré détecte par le niveau 1");
|
||||
assert!(
|
||||
!applies(&profile),
|
||||
"un agent structuré détecte par le niveau 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -324,8 +324,7 @@ mod tests {
|
||||
.enforce(&plan)
|
||||
.expect("an Allow fallback never fails closed");
|
||||
let read = std::fs::read(outside_t.join("pre.txt"));
|
||||
let write =
|
||||
std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
|
||||
let write = std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind());
|
||||
(status, read, write)
|
||||
})
|
||||
.join()
|
||||
|
||||
@ -24,7 +24,7 @@ use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use domain::ids::ScheduleId;
|
||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
||||
use domain::ports::{Clock, ScheduledTask, Scheduler};
|
||||
|
||||
/// Adapter tokio du port [`Scheduler`] (§21.4). Construire avec [`TokioScheduler::new`].
|
||||
///
|
||||
@ -111,7 +111,7 @@ mod tests {
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::ids::{AgentId, NodeId, ScheduleId};
|
||||
use domain::ports::{Clock, Scheduler, ScheduledTask};
|
||||
use domain::ports::{Clock, ScheduledTask, Scheduler};
|
||||
|
||||
use super::TokioScheduler;
|
||||
use crate::SystemClock;
|
||||
@ -126,7 +126,11 @@ mod tests {
|
||||
|
||||
/// Construit un scheduler + le bout récepteur du canal de remise + l'horloge réelle
|
||||
/// (partagée, pour calculer des échéances cohérentes avec celle qu'`arm` lira).
|
||||
fn make() -> (TokioScheduler, UnboundedReceiver<ScheduledTask>, Arc<dyn Clock>) {
|
||||
fn make() -> (
|
||||
TokioScheduler,
|
||||
UnboundedReceiver<ScheduledTask>,
|
||||
Arc<dyn Clock>,
|
||||
) {
|
||||
let (tx, rx) = unbounded_channel();
|
||||
let clock: Arc<dyn Clock> = Arc::new(SystemClock::new());
|
||||
(TokioScheduler::new(tx, clock.clone()), rx, clock)
|
||||
|
||||
@ -122,6 +122,8 @@ pub struct CodexExecSession {
|
||||
command: String,
|
||||
/// Répertoire de travail (run dir isolé §14.1).
|
||||
cwd: String,
|
||||
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
|
||||
writable_roots: Vec<String>,
|
||||
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||
conversation_id: Mutex<Option<String>>,
|
||||
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||
@ -143,6 +145,7 @@ impl CodexExecSession {
|
||||
command: impl Into<String>,
|
||||
cwd: impl Into<String>,
|
||||
seed_conversation_id: Option<String>,
|
||||
writable_roots: Vec<String>,
|
||||
sandbox: Option<SandboxPlan>,
|
||||
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||
) -> Self {
|
||||
@ -150,6 +153,7 @@ impl CodexExecSession {
|
||||
id,
|
||||
command: command.into(),
|
||||
cwd: cwd.into(),
|
||||
writable_roots,
|
||||
conversation_id: Mutex::new(seed_conversation_id),
|
||||
sandbox,
|
||||
sandbox_enforcer,
|
||||
@ -160,18 +164,18 @@ impl CodexExecSession {
|
||||
///
|
||||
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
||||
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
|
||||
/// --sandbox workspace-write <prompt>`.
|
||||
/// --sandbox workspace-write --add-dir <project-root> <prompt>`.
|
||||
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
|
||||
/// --skip-git-repo-check --sandbox workspace-write <prompt>`.
|
||||
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root>
|
||||
/// <prompt>`.
|
||||
///
|
||||
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
|
||||
/// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt
|
||||
/// d'approbation possible), donc on ne passe **pas** `--ask-for-approval` : ce flag
|
||||
/// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui
|
||||
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Défaut
|
||||
/// raisonnable, aligné sur l'autonomie projet (CLAUDE.md §12) ; à terme **piloté par
|
||||
/// les permissions de l'agent** (`.ideai/permissions.json` + sandbox OS) — non
|
||||
/// implémenté ici.
|
||||
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Comme l'agent
|
||||
/// tourne depuis son run dir isolé, `--add-dir` expose explicitement le project root
|
||||
/// à la sandbox Codex pour que les écritures Git touchent le vrai workspace.
|
||||
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||
let mut args = vec!["exec".to_owned()];
|
||||
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
|
||||
@ -182,6 +186,10 @@ impl CodexExecSession {
|
||||
args.push("--skip-git-repo-check".to_owned());
|
||||
args.push("--sandbox".to_owned());
|
||||
args.push("workspace-write".to_owned());
|
||||
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(root.clone());
|
||||
}
|
||||
args.push(prompt.to_owned());
|
||||
SpawnLine {
|
||||
command: self.command.clone(),
|
||||
|
||||
@ -79,7 +79,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
async fn start(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
_ctx: &PreparedContext,
|
||||
ctx: &PreparedContext,
|
||||
cwd: &ProjectPath,
|
||||
session: &SessionPlan,
|
||||
sandbox: Option<&SandboxPlan>,
|
||||
@ -109,12 +109,18 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
||||
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
||||
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
||||
let session: Arc<dyn AgentSession> = match adapter {
|
||||
StructuredAdapter::Claude => {
|
||||
Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer))
|
||||
}
|
||||
StructuredAdapter::Codex => {
|
||||
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
|
||||
}
|
||||
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
|
||||
id, command, cwd, seed, plan, enforcer,
|
||||
)),
|
||||
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
|
||||
id,
|
||||
command,
|
||||
cwd,
|
||||
seed,
|
||||
vec![ctx.project_root.clone()],
|
||||
plan,
|
||||
enforcer,
|
||||
)),
|
||||
};
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@ -62,6 +62,7 @@ mod tests {
|
||||
PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "CLAUDE.md".to_owned(),
|
||||
project_root: "/project".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,8 +241,7 @@ mod tests {
|
||||
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
|
||||
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
|
||||
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
|
||||
let completed =
|
||||
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
||||
let completed = codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
|
||||
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
|
||||
|
||||
let msg = codex::parse_event(
|
||||
@ -310,6 +310,7 @@ mod tests {
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
@ -321,7 +322,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn stream_is_closed_after_final() {
|
||||
let fake = FakeCli::printing(&claude_script());
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let session = ClaudeSdkSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let stream = session.send("x").await.expect("send ok");
|
||||
let events: Vec<_> = stream.collect();
|
||||
let after_final = events
|
||||
@ -339,7 +347,14 @@ mod tests {
|
||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||
"{ ceci n'est pas du json",
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let session = ClaudeSdkSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
match session.send("x").await {
|
||||
Err(AgentSessionError::Decode(_)) => {}
|
||||
Err(other) => panic!("attendu Decode, vu: {other:?}"),
|
||||
@ -437,6 +452,42 @@ mod tests {
|
||||
assert_eq!(content_cx, "réponse Codex");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn factory_passes_project_root_to_codex_add_dir() {
|
||||
let factory = StructuredSessionFactory::new();
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let codex = structured_profile(StructuredAdapter::Codex, &cmd);
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "AGENTS.md".to_owned(),
|
||||
project_root: "/project/root".to_owned(),
|
||||
};
|
||||
|
||||
let session = factory
|
||||
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None)
|
||||
.await
|
||||
.expect("start Codex ok");
|
||||
let content = drain_final(session.as_ref()).await;
|
||||
assert_eq!(content, "ok");
|
||||
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
assert!(
|
||||
args.windows(2).any(|w| w == ["--add-dir", "/project/root"]),
|
||||
"factory must relay PreparedContext.project_root to Codex --add-dir, got: {args:?}"
|
||||
);
|
||||
assert!(
|
||||
!args.contains(&"--ask-for-approval"),
|
||||
"codex exec must not receive unsupported approval flags, got: {args:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn factory_resume_seeds_conversation_id() {
|
||||
let factory = StructuredSessionFactory::new();
|
||||
@ -564,10 +615,14 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static C: AtomicU64 = AtomicU64::new(0);
|
||||
let n = C.fetch_add(1, Ordering::Relaxed);
|
||||
let mut bin = std::env::temp_dir();
|
||||
bin.push(format!("idea-rec-cli-{}-{n}", std::process::id()));
|
||||
let mut argv = std::env::temp_dir();
|
||||
argv.push(format!("idea-rec-argv-{}-{n}", std::process::id()));
|
||||
let dir = std::env::current_dir()
|
||||
.expect("cwd")
|
||||
.join("target")
|
||||
.join("test-fakes")
|
||||
.join("session");
|
||||
std::fs::create_dir_all(&dir).expect("create rec fake dir");
|
||||
let bin = dir.join(format!("idea-rec-cli-{}-{n}", std::process::id()));
|
||||
let argv = dir.join(format!("idea-rec-argv-{}-{n}", std::process::id()));
|
||||
|
||||
let mut s = String::from("#!/bin/sh\n");
|
||||
// Enregistre chaque argument sur sa propre ligne dans le sidecar.
|
||||
@ -775,7 +830,15 @@ mod tests {
|
||||
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
|
||||
r#"{"type":"turn.completed","usage":{}}"#,
|
||||
]);
|
||||
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let s = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
let finals = events
|
||||
.iter()
|
||||
@ -789,7 +852,10 @@ mod tests {
|
||||
.skip(1)
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
|
||||
assert_eq!(
|
||||
after_final_terminals, 0,
|
||||
"aucun second Final après le premier"
|
||||
);
|
||||
}
|
||||
|
||||
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
|
||||
@ -803,7 +869,14 @@ mod tests {
|
||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
||||
]);
|
||||
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let s = ClaudeSdkSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
||||
let finals = events
|
||||
.iter()
|
||||
@ -959,6 +1032,7 @@ mod tests {
|
||||
cmd.clone(),
|
||||
"/",
|
||||
Some("cx-id".to_owned()),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
@ -983,7 +1057,8 @@ mod tests {
|
||||
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
|
||||
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
let session =
|
||||
ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
assert_eq!(session.conversation_id(), None);
|
||||
let _ = session.send("t1").await.expect("t1");
|
||||
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
||||
@ -1093,6 +1168,7 @@ mod tests {
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
@ -1123,7 +1199,14 @@ mod tests {
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let session = ClaudeSdkSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
||||
assert_eq!(
|
||||
events,
|
||||
@ -1160,7 +1243,8 @@ mod tests {
|
||||
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
||||
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
let session =
|
||||
ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
let _ = session.send("bonjour").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
@ -1192,7 +1276,15 @@ mod tests {
|
||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
let session = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
cmd.clone(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = session.send("salut").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
@ -1217,7 +1309,8 @@ mod tests {
|
||||
// =====================================================================
|
||||
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
|
||||
// d'écriture Codex : la commande générée porte EXACTEMENT
|
||||
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write, <prompt>]
|
||||
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
|
||||
// --add-dir, <project-root>, <prompt>]
|
||||
// (resume <id> en tête pour une reprise). Le flag `--ask-for-approval never`
|
||||
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
|
||||
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
|
||||
@ -1226,15 +1319,23 @@ mod tests {
|
||||
// =====================================================================
|
||||
|
||||
/// Conversation NEUVE : argv EXACT `[exec, --json, --skip-git-repo-check,
|
||||
/// --sandbox, workspace-write, <prompt>]`. Pas de sous-commande `resume`,
|
||||
/// pas de `--ask-for-approval`.
|
||||
/// --sandbox, workspace-write, --add-dir, <project-root>, <prompt>]`.
|
||||
/// Pas de sous-commande `resume`, pas de `--ask-for-approval`.
|
||||
#[tokio::test]
|
||||
async fn codex_new_conversation_command_carries_exact_args() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
||||
]);
|
||||
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
|
||||
let session = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
cmd.clone(),
|
||||
"/",
|
||||
None,
|
||||
vec!["/project/root".to_owned()],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let _ = session.send("salut").await.expect("send ok");
|
||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||
let args: Vec<&str> = recorded.lines().collect();
|
||||
@ -1247,6 +1348,8 @@ mod tests {
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--add-dir",
|
||||
"/project/root",
|
||||
"salut",
|
||||
],
|
||||
"argv neuf doit être exact (sans resume, sans --ask-for-approval), vu: {args:?}"
|
||||
@ -1256,8 +1359,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// REPRISE (seed d'id) : argv EXACT `[exec, resume, <id>, --json,
|
||||
/// --skip-git-repo-check, --sandbox, workspace-write, <prompt>]`. Toujours
|
||||
/// pas de `--ask-for-approval`.
|
||||
/// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, <project-root>,
|
||||
/// <prompt>]`. Toujours pas de `--ask-for-approval`.
|
||||
#[tokio::test]
|
||||
async fn codex_resume_command_carries_exact_args() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
@ -1268,6 +1371,7 @@ mod tests {
|
||||
cmd.clone(),
|
||||
"/",
|
||||
Some("cx-id".to_owned()),
|
||||
vec!["/project/root".to_owned()],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
@ -1285,6 +1389,8 @@ mod tests {
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--add-dir",
|
||||
"/project/root",
|
||||
"vas-y",
|
||||
],
|
||||
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
|
||||
@ -1339,13 +1445,19 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
|
||||
// < 10^12 ⇒ secondes ⇒ ×1000.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })), Some(1_700_000_000_000));
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_integer_millis_are_kept_as_is() {
|
||||
// ≥ 10^12 ⇒ déjà des millisecondes ⇒ tel quel.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })), Some(1_700_000_000_000));
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// Le SEUIL exact (10^12) : juste en-dessous ⇒ secondes (×1000) ; pile/au-dessus
|
||||
@ -1388,12 +1500,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_string_integer_uses_seconds_heuristic() {
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000" })), Some(1_700_000_000_000));
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1700000000" })),
|
||||
Some(1_700_000_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reset_ms_string_float_uses_seconds_heuristic() {
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000.5" })), Some(1_700_000_000_500));
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "1700000000.5" })),
|
||||
Some(1_700_000_000_500)
|
||||
);
|
||||
}
|
||||
|
||||
// ---- parse_reset_ms : ISO-8601 / RFC3339 (parseur maison) --------------
|
||||
@ -1487,11 +1605,20 @@ mod tests {
|
||||
// Pas de séparateur de date/heure.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
|
||||
// Année non numérique.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })), None);
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })),
|
||||
None
|
||||
);
|
||||
// Composante de date manquante (pas de jour).
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })), None);
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })),
|
||||
None
|
||||
);
|
||||
// Trop de composantes de date.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })), None);
|
||||
assert_eq!(
|
||||
parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })),
|
||||
None
|
||||
);
|
||||
// Minute manquante dans l'heure.
|
||||
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14T22Z" })), None);
|
||||
}
|
||||
@ -1614,7 +1741,14 @@ mod tests {
|
||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ap"}]},"session_id":"rl-1","parent_tool_use_id":null}"#,
|
||||
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"rl-1","num_turns":1}"#,
|
||||
]);
|
||||
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
|
||||
let session = ClaudeSdkSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
||||
assert_eq!(
|
||||
events,
|
||||
|
||||
@ -158,8 +158,7 @@ async fn run_turn_sandboxed(
|
||||
|
||||
// Thread JETABLE : sa restriction Landlock meurt avec lui.
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
||||
let result = drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
|
||||
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
|
||||
let _ = done_tx.send(result);
|
||||
});
|
||||
|
||||
@ -187,7 +187,10 @@ async fn structured_run_turn_without_plan_does_not_sandbox() {
|
||||
.expect("run_turn natif réussit");
|
||||
|
||||
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
|
||||
assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)");
|
||||
assert!(
|
||||
allowed_marker.exists(),
|
||||
"écriture in-grant réussit (sans plan)"
|
||||
);
|
||||
assert!(
|
||||
denied_marker.exists(),
|
||||
"sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale"
|
||||
@ -278,7 +281,10 @@ async fn structured_run_turn_fail_closed_no_child_on_enforce_err() {
|
||||
async fn structured_run_turn_none_plan_is_native_path() {
|
||||
let dir = fresh_dir("p4");
|
||||
let marker = dir.join("native.txt");
|
||||
let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display());
|
||||
let script = format!(
|
||||
"printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'",
|
||||
marker.display()
|
||||
);
|
||||
let spec = SpawnLine {
|
||||
command: "sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), script],
|
||||
@ -327,7 +333,10 @@ async fn structured_two_turns_disjoint_grants_are_confined() {
|
||||
run_turn(&spec_a, None, Some(&enforcer))
|
||||
.await
|
||||
.expect("tour A ok");
|
||||
assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir");
|
||||
assert!(
|
||||
a_in.exists(),
|
||||
"tour A: écriture dans son grant (A) doit réussir"
|
||||
);
|
||||
assert!(
|
||||
!a_into_b.exists(),
|
||||
"tour A: écriture dans B (hors grant A) doit être bloquée"
|
||||
@ -419,7 +428,9 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||
use super::factory::StructuredSessionFactory;
|
||||
|
||||
if !landlock_is_enforced() {
|
||||
eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible");
|
||||
eprintln!(
|
||||
"skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -448,6 +459,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "CLAUDE.md".to_owned(),
|
||||
project_root: run_dir.to_string_lossy().into_owned(),
|
||||
};
|
||||
let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu");
|
||||
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
|
||||
@ -466,7 +478,10 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
|
||||
.count();
|
||||
assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}");
|
||||
assert_eq!(
|
||||
finals, 1,
|
||||
"un Final attendu malgré le sandbox, vu: {events:?}"
|
||||
);
|
||||
|
||||
// LE POINT : l'id de conversation a bien été capté sous enforcement actif.
|
||||
assert_eq!(
|
||||
|
||||
@ -152,6 +152,7 @@ impl AgentContextStore for IdeaiContextStore {
|
||||
Err(FsError::NotFound(_)) => Ok(AgentManifest {
|
||||
version: MANIFEST_VERSION,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
}),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
|
||||
@ -186,7 +186,11 @@ fn split_seconds_frac(s: &str) -> Option<(i64, i64)> {
|
||||
return Some((s.parse::<i64>().ok()?, 0));
|
||||
};
|
||||
let sec = sec.parse::<i64>().ok()?;
|
||||
let mut d3: String = frac.chars().take_while(char::is_ascii_digit).take(3).collect();
|
||||
let mut d3: String = frac
|
||||
.chars()
|
||||
.take_while(char::is_ascii_digit)
|
||||
.take(3)
|
||||
.collect();
|
||||
while d3.len() < 3 {
|
||||
d3.push('0');
|
||||
}
|
||||
@ -257,7 +261,10 @@ mod tests {
|
||||
fn wall_clock_to_ms_same_day_when_future() {
|
||||
let now_10h = DAY_START + 10 * 3_600_000;
|
||||
// 15:00 est dans le futur ⇒ même jour.
|
||||
assert_eq!(wall_clock_to_ms(now_10h, 15, 0, 0), DAY_START + 15 * 3_600_000);
|
||||
assert_eq!(
|
||||
wall_clock_to_ms(now_10h, 15, 0, 0),
|
||||
DAY_START + 15 * 3_600_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -282,7 +289,8 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_absolute_ms_epoch_seconds_and_millis() {
|
||||
assert_eq!(parse_absolute_ms("1700000000"), Some(1_700_000_000_000)); // s ⇒ ×1000
|
||||
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000)); // ms tel quel
|
||||
assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000));
|
||||
// ms tel quel
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -45,6 +45,7 @@ fn ctx() -> PreparedContext {
|
||||
PreparedContext {
|
||||
content: MarkdownDoc::new("# hi"),
|
||||
relative_path: ".ideai/agent.md".to_owned(),
|
||||
project_root: "/repo".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -151,3 +151,63 @@ async fn manifest_file_is_camelcase_json_under_ideai() {
|
||||
);
|
||||
assert!(entry.get("md_path").is_none(), "no snake_case leak");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_manifest_without_orchestrator_loads_as_default() {
|
||||
// A pre-feature `agents.json` has no `orchestrator` key. It must deserialise with
|
||||
// `orchestrator == None` (serde default), so `effective_orchestrator()` falls back
|
||||
// to the oldest entry (free backward compatibility).
|
||||
let tmp = TempDir::new();
|
||||
let p = project(&tmp.root());
|
||||
let fs = LocalFileSystem::new();
|
||||
fs.create_dir_all(&tmp.child(".ideai")).await.unwrap();
|
||||
let legacy = r#"{
|
||||
"version": 1,
|
||||
"agents": [
|
||||
{ "agentId": "00000000-0000-0000-0000-000000000001", "name": "Oldest",
|
||||
"mdPath": "agents/oldest.md", "profileId": "00000000-0000-0000-0000-0000000000aa",
|
||||
"synchronized": false },
|
||||
{ "agentId": "00000000-0000-0000-0000-000000000002", "name": "Newer",
|
||||
"mdPath": "agents/newer.md", "profileId": "00000000-0000-0000-0000-0000000000aa",
|
||||
"synchronized": false }
|
||||
]
|
||||
}"#;
|
||||
fs.write(&tmp.child(".ideai/agents.json"), legacy.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let manifest = store().load_manifest(&p).await.unwrap();
|
||||
assert_eq!(manifest.orchestrator, None);
|
||||
assert_eq!(manifest.effective_orchestrator(), Some(aid(1)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_with_orchestrator_roundtrips_and_emits_camelcase_key() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store();
|
||||
let p = project(&tmp.root());
|
||||
|
||||
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let b = agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
|
||||
let mut manifest = AgentManifest::new(
|
||||
1,
|
||||
vec![ManifestEntry::from_agent(&a), ManifestEntry::from_agent(&b)],
|
||||
)
|
||||
.unwrap();
|
||||
manifest.designate(aid(2)).unwrap();
|
||||
store.save_manifest(&p, &manifest).await.unwrap();
|
||||
|
||||
// Round-trips byte-for-byte through the store…
|
||||
let back = store.load_manifest(&p).await.unwrap();
|
||||
assert_eq!(back, manifest);
|
||||
assert_eq!(back.effective_orchestrator(), Some(aid(2)));
|
||||
|
||||
// …and the on-disk JSON carries the camelCase `orchestrator` key.
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
json.get("orchestrator").and_then(|v| v.as_str()),
|
||||
Some("00000000-0000-0000-0000-000000000002")
|
||||
);
|
||||
}
|
||||
|
||||
@ -78,6 +78,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
@ -1227,21 +1228,18 @@ async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() {
|
||||
agent_id,
|
||||
SessionId::from_uuid(Uuid::from_u128(910)),
|
||||
);
|
||||
let server = server(service)
|
||||
.with_ask_rendezvous_timeout(std::time::Duration::from_millis(50));
|
||||
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 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:?}");
|
||||
|
||||
@ -74,6 +74,7 @@ impl FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: Vec::new(),
|
||||
orchestrator: None,
|
||||
},
|
||||
contents: HashMap::new(),
|
||||
})))
|
||||
|
||||
Reference in New Issue
Block a user