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:
@ -18,6 +18,7 @@
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::permission::ProjectorKey;
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
StructuredAdapter,
|
||||
@ -61,6 +62,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
)
|
||||
.expect("claude reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_projector(ProjectorKey::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json")
|
||||
.expect(".mcp.json is a valid relative MCP config target"),
|
||||
@ -79,6 +81,7 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
)
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
// Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` :
|
||||
// IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour
|
||||
@ -201,4 +204,30 @@ mod mcp_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Lot LP3 : projector (clé du projecteur de permissions par-CLI) ----------
|
||||
|
||||
#[test]
|
||||
fn claude_and_codex_seed_their_projector_key() {
|
||||
assert_eq!(
|
||||
profile("claude").projector,
|
||||
Some(ProjectorKey::Claude),
|
||||
"the Claude seed must pose the Claude projector"
|
||||
);
|
||||
assert_eq!(
|
||||
profile("codex").projector,
|
||||
Some(ProjectorKey::Codex),
|
||||
"the Codex seed must pose the Codex projector"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_and_aider_have_no_projector() {
|
||||
for slug in ["gemini", "aider"] {
|
||||
assert!(
|
||||
profile(slug).projector.is_none(),
|
||||
"non-structured profile `{slug}` must NOT carry a projector (native prompting)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,17 +11,21 @@
|
||||
//! [`AgentRuntime`], [`PtyPort`], [`FileSystem`], [`EventBus`]); none knows about
|
||||
//! a concrete adapter or Tauri.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
|
||||
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore,
|
||||
ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
|
||||
ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
|
||||
StoreError,
|
||||
};
|
||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId,
|
||||
ConversationParty, DomainEvent, Handoff, HandoffStore, ManifestEntry, MarkdownDoc,
|
||||
MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project, ProjectPath, ProviderSessionStore,
|
||||
ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry,
|
||||
MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId,
|
||||
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
||||
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
|
||||
@ -367,6 +371,13 @@ pub struct ChangeAgentProfile {
|
||||
/// lieu de tuer un PTY. Injecté au câblage via [`Self::with_structured`] ; `None`
|
||||
/// ⇒ seul le registre PTY est consulté (mode legacy / tests existants).
|
||||
structured: Option<Arc<StructuredSessions>>,
|
||||
/// Registre des **projecteurs de permissions** (lot LP3-4), pour nettoyer au swap
|
||||
/// les fichiers `Replace` orphelins possédés par l'ANCIEN profil que le nouveau ne
|
||||
/// réécrira pas. Injecté via [`Self::with_permission_projectors`] (le câblage passe
|
||||
/// le **même** `Arc` que celui donné au `LaunchAgent` interne). `None` ⇒ aucun
|
||||
/// nettoyage (comportement legacy ; la re-projection du nouveau profil, elle, reste
|
||||
/// assurée par le `LaunchAgent` composé).
|
||||
projectors: Option<Arc<PermissionProjectorRegistry>>,
|
||||
}
|
||||
|
||||
impl ChangeAgentProfile {
|
||||
@ -393,6 +404,7 @@ impl ChangeAgentProfile {
|
||||
launch,
|
||||
events,
|
||||
structured: None,
|
||||
projectors: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -406,6 +418,20 @@ impl ChangeAgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le **registre de projecteurs de permissions** (lot LP3-4) pour le
|
||||
/// nettoyage des fichiers orphelins au swap cross-profile. Le câblage passe le
|
||||
/// **même** `Arc<PermissionProjectorRegistry>` que celui injecté au `LaunchAgent`
|
||||
/// interne (source unique de vérité). Builder additif : signature de [`Self::new`]
|
||||
/// inchangée ; `None` ⇒ pas de nettoyage (legacy).
|
||||
#[must_use]
|
||||
pub fn with_permission_projectors(
|
||||
mut self,
|
||||
projectors: Arc<PermissionProjectorRegistry>,
|
||||
) -> Self {
|
||||
self.projectors = Some(projectors);
|
||||
self
|
||||
}
|
||||
|
||||
/// Executes the hot-swap, following the 7-step algorithm of §15.1.
|
||||
///
|
||||
/// # Errors
|
||||
@ -425,6 +451,10 @@ impl ChangeAgentProfile {
|
||||
.find(|e| e.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
|
||||
// Capture the PREVIOUS profile id BEFORE mutation (lot LP3-4): it identifies
|
||||
// the projector whose now-orphan `Replace` files must be cleaned up at the swap.
|
||||
let previous_profile_id = entry.profile_id;
|
||||
|
||||
// 2. Same profile ⇒ no-op: return the agent unchanged, no kill/relaunch,
|
||||
// no event.
|
||||
if entry.profile_id == input.profile_id {
|
||||
@ -438,15 +468,16 @@ impl ChangeAgentProfile {
|
||||
}
|
||||
|
||||
// 3. Validate that the target profile is a known one (ProfileStore.list).
|
||||
let known = self
|
||||
.profiles
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.any(|p| p.id == input.profile_id);
|
||||
if !known {
|
||||
// Capture both the NEW profile (validation) and the PREVIOUS profile (for
|
||||
// the LP3-4 cleanup; absent if it was deleted ⇒ cleanup is skipped).
|
||||
let profiles = self.profiles.list().await?;
|
||||
let Some(new_profile) = profiles.iter().find(|p| p.id == input.profile_id).cloned() else {
|
||||
return Err(AppError::NotFound(format!("profile {}", input.profile_id)));
|
||||
}
|
||||
};
|
||||
let previous_profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id == previous_profile_id)
|
||||
.cloned();
|
||||
|
||||
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
|
||||
// and persist.
|
||||
@ -478,6 +509,20 @@ impl ChangeAgentProfile {
|
||||
.invalidate_engine_link(&input.project, &input.agent_id)
|
||||
.await?;
|
||||
|
||||
// 5b. (lot LP3-4) Clean up the PREVIOUS profile's now-orphan permission files
|
||||
// in the agent's (stable) run dir, BEFORE the relaunch re-projects the new
|
||||
// profile — so we never delete a file the new profile is about to write.
|
||||
// Only `Replace`-owned paths of the old projector that the new projector
|
||||
// does NOT also own are removed (best-effort). `MergeToml` files are never
|
||||
// touched. No-op without a projector registry.
|
||||
self.cleanup_swapped_out_files(
|
||||
&input.project.root,
|
||||
&input.agent_id,
|
||||
previous_profile.as_ref(),
|
||||
&new_profile,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 6. A live session? Kill its PTY then relaunch in the same cell with the
|
||||
// new profile, carrying the **preserved** pair id (so the handoff is
|
||||
// re-injected and resume routes via providers.json[new provider]).
|
||||
@ -555,6 +600,66 @@ impl ChangeAgentProfile {
|
||||
Ok(pair_id)
|
||||
}
|
||||
|
||||
/// Removes the **previous** profile's now-orphan permission files at the swap
|
||||
/// (lot LP3-4), best-effort. Because the run dir is **stable per agent id**
|
||||
/// (`agent_run_dir`), the old CLI's config (e.g. `.claude/settings.local.json`)
|
||||
/// survives a swap in the very same directory; left in place it is stale.
|
||||
///
|
||||
/// Cleanup rule (Architect-validated): delete exactly
|
||||
/// `owned_replace_paths(old) − owned_replace_paths(new)` — only the
|
||||
/// **`Replace`-owned** files of the old projector that the new projector will not
|
||||
/// re-own (and thus re-write). `MergeToml` (co-owned, e.g. `.codex/config.toml`)
|
||||
/// files are **never** deleted. Examples: Claude→Codex removes
|
||||
/// `.claude/settings.local.json`; Claude→Claude removes nothing (empty diff);
|
||||
/// Codex→Claude removes nothing on the Replace side (Codex owns no Replace file).
|
||||
///
|
||||
/// No-op when: no registry is wired, the previous profile is unknown (deleted),
|
||||
/// or it maps to no projector. Every delete is best-effort (`remove_file` is
|
||||
/// idempotent), so a missing file never fails the swap. This does **not** touch
|
||||
/// the stable pair id or the handoff (P8d) — it only removes engine config files.
|
||||
async fn cleanup_swapped_out_files(
|
||||
&self,
|
||||
project_root: &ProjectPath,
|
||||
agent_id: &AgentId,
|
||||
previous_profile: Option<&AgentProfile>,
|
||||
new_profile: &AgentProfile,
|
||||
) {
|
||||
let Some(registry) = &self.projectors else {
|
||||
return;
|
||||
};
|
||||
let Some(previous_profile) = previous_profile else {
|
||||
return;
|
||||
};
|
||||
let Some(old_key) = select_projector_key(previous_profile) else {
|
||||
return;
|
||||
};
|
||||
let Some(old_projector) = registry.get(old_key) else {
|
||||
return;
|
||||
};
|
||||
let old_owned = old_projector.owned_replace_paths();
|
||||
if old_owned.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Paths the NEW projector will re-own (and re-write) ⇒ never delete these.
|
||||
let kept: Vec<String> = select_projector_key(new_profile)
|
||||
.and_then(|key| registry.get(key))
|
||||
.map(|p| p.owned_replace_paths())
|
||||
.unwrap_or_default();
|
||||
|
||||
let run_dir = match agent_run_dir(project_root, agent_id) {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return,
|
||||
};
|
||||
for rel in old_owned {
|
||||
if kept.iter().any(|k| k == &rel) {
|
||||
continue;
|
||||
}
|
||||
let path = RemotePath::new(join(&run_dir, &rel));
|
||||
// Best-effort: a missing file / delete failure must never fail the swap.
|
||||
let _ = self.fs.remove_file(&path).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kills the agent's live PTY (if any) and relaunches it in the same cell with
|
||||
/// the new profile (step 6), composing [`LaunchAgent::execute`]. Returns the
|
||||
/// relaunched session, or `None` when the agent had no live session.
|
||||
@ -832,6 +937,81 @@ pub struct LaunchAgentOutput {
|
||||
pub structured: Option<StructuredSessionDescriptor>,
|
||||
}
|
||||
|
||||
/// Registry mapping each [`ProjectorKey`] to its concrete
|
||||
/// [`PermissionProjector`] (lot LP3-3). Injected into [`LaunchAgent`] via the
|
||||
/// optional builder [`LaunchAgent::with_permission_projectors`]; the concrete
|
||||
/// projectors (Claude / Codex) live in `infrastructure` and are inserted at the
|
||||
/// composition root (lot LP3-5). Absent registry ⇒ no projection (legacy
|
||||
/// behaviour preserved).
|
||||
#[derive(Default, Clone)]
|
||||
pub struct PermissionProjectorRegistry {
|
||||
by_key: HashMap<ProjectorKey, Arc<dyn PermissionProjector>>,
|
||||
}
|
||||
|
||||
impl PermissionProjectorRegistry {
|
||||
/// An empty registry.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers a projector under its own [`PermissionProjector::key`] and returns
|
||||
/// `self` (builder style), so a registry can be assembled fluently.
|
||||
#[must_use]
|
||||
pub fn with(mut self, projector: Arc<dyn PermissionProjector>) -> Self {
|
||||
self.insert(projector);
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers (or replaces) a projector under its own key.
|
||||
pub fn insert(&mut self, projector: Arc<dyn PermissionProjector>) {
|
||||
self.by_key.insert(projector.key(), projector);
|
||||
}
|
||||
|
||||
/// Returns the projector registered for `key`, if any.
|
||||
#[must_use]
|
||||
pub fn get(&self, key: ProjectorKey) -> Option<&Arc<dyn PermissionProjector>> {
|
||||
self.by_key.get(&key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the [`ProjectorKey`] for a launched agent's profile (lot LP3-3).
|
||||
///
|
||||
/// Primary source: the profile's explicit `projector` field. **Migration
|
||||
/// fallback** for legacy profiles (e.g. an old `profiles.json` written before the
|
||||
/// field existed): the historical heuristic — a `CLAUDE.md` convention file ⇒
|
||||
/// Claude; a `StructuredAdapter::Codex` or a `TomlConfigHome` MCP strategy ⇒
|
||||
/// Codex. Anything else ⇒ `None` (no projection).
|
||||
fn select_projector_key(profile: &AgentProfile) -> Option<ProjectorKey> {
|
||||
if let Some(key) = profile.projector {
|
||||
return Some(key);
|
||||
}
|
||||
// Legacy fallback: Claude is recognised by its `CLAUDE.md` convention file.
|
||||
let is_claude = matches!(
|
||||
&profile.context_injection,
|
||||
ContextInjection::ConventionFile { target }
|
||||
if target
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
.eq_ignore_ascii_case("CLAUDE.md")
|
||||
);
|
||||
if is_claude {
|
||||
return Some(ProjectorKey::Claude);
|
||||
}
|
||||
// Legacy fallback: Codex is recognised by its structured adapter or its
|
||||
// CODEX_HOME-isolated TOML MCP strategy.
|
||||
if matches!(profile.structured_adapter, Some(StructuredAdapter::Codex)) {
|
||||
return Some(ProjectorKey::Codex);
|
||||
}
|
||||
if let Some(mcp) = &profile.mcp {
|
||||
if matches!(mcp.config, McpConfigStrategy::TomlConfigHome { .. }) {
|
||||
return Some(ProjectorKey::Codex);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
||||
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
|
||||
///
|
||||
@ -859,6 +1039,10 @@ pub struct LaunchAgent {
|
||||
/// reads the project memory. `None` keeps the launcher independent of it (legacy
|
||||
/// wiring / tests). A failure here never affects the launch.
|
||||
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
||||
/// Optional permissions store (LP1). When absent, the launcher keeps its
|
||||
/// historical hardcoded CLI permission seeds. When present and a project or
|
||||
/// agent policy is configured, the resolved policy is projected to the CLI.
|
||||
permissions: Option<Arc<dyn PermissionStore>>,
|
||||
/// Fabrique des sessions **structurées** (IA, §17). Injectée au câblage
|
||||
/// (composition root) via [`Self::with_structured`]. `None` ⇒ le routage §17.4
|
||||
/// est désactivé et **tout** profil suit le chemin PTY historique (mode legacy /
|
||||
@ -882,6 +1066,10 @@ pub struct LaunchAgent {
|
||||
/// régression pour les call sites/tests legacy). Une écriture en échec ⇒ lancement
|
||||
/// normal, jamais d'échec.
|
||||
provider_sessions: Option<Arc<dyn ProviderSessionProvider>>,
|
||||
/// Registre des **projecteurs de permissions** par CLI (lot LP3-3). Injecté au
|
||||
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
|
||||
/// (comportement historique préservé pour les call sites/tests legacy).
|
||||
projectors: Option<Arc<PermissionProjectorRegistry>>,
|
||||
}
|
||||
|
||||
impl LaunchAgent {
|
||||
@ -913,13 +1101,36 @@ impl LaunchAgent {
|
||||
ids,
|
||||
recall,
|
||||
embedder_suggestion,
|
||||
permissions: None,
|
||||
session_factory: None,
|
||||
structured: None,
|
||||
handoffs: None,
|
||||
provider_sessions: None,
|
||||
projectors: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
|
||||
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
|
||||
/// concrete permission config. Without this call (legacy call sites / tests),
|
||||
/// no projection happens — signature of [`Self::new`] unchanged.
|
||||
#[must_use]
|
||||
pub fn with_permission_projectors(
|
||||
mut self,
|
||||
projectors: Arc<PermissionProjectorRegistry>,
|
||||
) -> Self {
|
||||
self.projectors = Some(projectors);
|
||||
self
|
||||
}
|
||||
|
||||
/// Injects the project permission store used to resolve effective agent
|
||||
/// permissions at launch time.
|
||||
#[must_use]
|
||||
pub fn with_permission_store(mut self, permissions: Arc<dyn PermissionStore>) -> Self {
|
||||
self.permissions = Some(permissions);
|
||||
self
|
||||
}
|
||||
|
||||
/// Branche le provider de **store de sessions provider (lot P8b)** sur ce launcher :
|
||||
/// après un lancement structuré exposant un id de session moteur, range
|
||||
/// `(pair_conversation_id, provider_key) → engine_session_id` dans `providers.json`
|
||||
@ -1205,16 +1416,14 @@ impl LaunchAgent {
|
||||
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
|
||||
.await?;
|
||||
|
||||
// 3b. Seed the CLI's permission config in the run dir so the agent runs
|
||||
// with the project's full autonomy and never blocks on per-command
|
||||
// permission prompts. The agent's cwd is the run dir, so the CLI
|
||||
// writes/reads its permission file there; without a seed, the CLI
|
||||
// accumulates narrow per-command approvals and keeps prompting.
|
||||
// Pragmatic per-CLI seed pending the universal `.ideai/permissions.json`
|
||||
// + OS-sandbox model. Non-clobbering and best-effort.
|
||||
self.seed_cli_permissions(&profile, &run_dir, &input.project.root)
|
||||
let effective_permissions = self
|
||||
.resolve_effective_permissions(&input.project, agent.id)
|
||||
.await?;
|
||||
|
||||
// 3b. (Permission projection moved to step 5c, after the convention file and
|
||||
// the MCP config, so both the structured and PTY paths inherit it — see
|
||||
// `apply_permission_projection`.)
|
||||
|
||||
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
|
||||
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
|
||||
// placeholder resolves against it.
|
||||
@ -1277,8 +1486,29 @@ impl LaunchAgent {
|
||||
// IdeA matérialise SA config MCP au format de CETTE CLI, dans le **même**
|
||||
// run dir isolé que le convention file et le seed de permissions. `None`
|
||||
// ⇒ aucun write/flag/env (chemin actuel inchangé, zéro régression).
|
||||
self.apply_mcp_config(&profile, &run_dir, input.mcp_runtime.as_ref(), &mut spec)
|
||||
.await;
|
||||
self.apply_mcp_config(
|
||||
&profile,
|
||||
&run_dir,
|
||||
&input.project.root,
|
||||
input.mcp_runtime.as_ref(),
|
||||
&mut spec,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
|
||||
// Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
|
||||
// AVANT le split structuré/PTY (5b) et le spawn : les DEUX chemins
|
||||
// héritent de la projection (fichiers du plan écrits dans le run dir,
|
||||
// `args`/`env` foldés dans `spec`). `None` registre / profil non
|
||||
// projetable / `eff == None` ⇒ no-op (prompting natif conservé).
|
||||
self.apply_permission_projection(
|
||||
&profile,
|
||||
&run_dir,
|
||||
&input.project.root,
|
||||
effective_permissions.as_ref(),
|
||||
&mut spec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ──
|
||||
// Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le
|
||||
@ -1575,53 +1805,114 @@ impl LaunchAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds the agent's run dir with the CLI permission config matching its
|
||||
/// context-injection convention, so the agent inherits the project's autonomy
|
||||
/// instead of prompting per command.
|
||||
/// Projects the resolved [`EffectivePermissions`] into the launched CLI's
|
||||
/// concrete permission config (lot LP3-3), replacing the historical per-CLI
|
||||
/// seeds. Selects the profile's [`PermissionProjector`] (cf.
|
||||
/// [`select_projector_key`]), asks it for a pure [`domain::permission::PermissionProjection`]
|
||||
/// (a plan), then **applies** that plan: materialises its files in the run dir
|
||||
/// and folds its `args`/`env` into `spec`.
|
||||
///
|
||||
/// Conditioned on the CLI convention (only Claude Code — convention file
|
||||
/// `CLAUDE.md` — has a known seed today); a no-op for any other CLI.
|
||||
/// Best-effort and **non-clobbering**: an existing file (possibly user-edited)
|
||||
/// is left untouched.
|
||||
/// File ownership drives the write regime:
|
||||
/// - [`ProjectedFile::Replace`] ⇒ **clobber** (always overwrite). Unlike the old
|
||||
/// non-clobbering seed, this lets a re-projection (e.g. after a profile swap)
|
||||
/// refresh an IdeA-owned file.
|
||||
/// - [`ProjectedFile::MergeToml`] ⇒ merge only the **managed** tables/keys into
|
||||
/// any existing file (via the shared TOML helpers), preserving everything else.
|
||||
///
|
||||
/// No-op when: no registry is wired, the profile has no matching projector, or
|
||||
/// the resolved permissions are `None` (the projector returns an empty
|
||||
/// projection — native prompting preserved).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::FileSystem`] if the directory/file cannot be written.
|
||||
async fn seed_cli_permissions(
|
||||
/// [`AppError::FileSystem`] if a plan file cannot be written.
|
||||
async fn apply_permission_projection(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
run_dir: &ProjectPath,
|
||||
project_root: &ProjectPath,
|
||||
permissions: Option<&EffectivePermissions>,
|
||||
spec: &mut SpawnSpec,
|
||||
) -> Result<(), AppError> {
|
||||
let is_claude = matches!(
|
||||
&profile.context_injection,
|
||||
ContextInjection::ConventionFile { target }
|
||||
if target
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
.eq_ignore_ascii_case("CLAUDE.md")
|
||||
);
|
||||
if !is_claude {
|
||||
let Some(registry) = &self.projectors else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(key) = select_projector_key(profile) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(projector) = registry.get(key) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let ctx = ProjectionContext {
|
||||
project_root: project_root.as_str(),
|
||||
run_dir: run_dir.as_str(),
|
||||
};
|
||||
let projection = projector.project(permissions, &ctx);
|
||||
|
||||
for file in &projection.files {
|
||||
match file {
|
||||
ProjectedFile::Replace { rel_path, contents } => {
|
||||
self.ensure_run_dir_parent(run_dir, rel_path).await?;
|
||||
let path = RemotePath::new(join(run_dir, rel_path));
|
||||
// Clobber: IdeA owns this file; overwriting refreshes it on re-projection.
|
||||
self.fs.write(&path, contents.as_bytes()).await?;
|
||||
}
|
||||
ProjectedFile::MergeToml {
|
||||
rel_path,
|
||||
managed_tables,
|
||||
managed_keys,
|
||||
contents,
|
||||
} => {
|
||||
self.ensure_run_dir_parent(run_dir, rel_path).await?;
|
||||
let path = RemotePath::new(join(run_dir, rel_path));
|
||||
let existing = match self.fs.read(&path).await {
|
||||
Ok(bytes) => String::from_utf8(bytes).unwrap_or_default(),
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
let merged =
|
||||
merge_managed_toml(&existing, managed_tables, managed_keys, contents);
|
||||
self.fs.write(&path, merged.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let settings_path =
|
||||
RemotePath::new(format!("{}/.claude/settings.local.json", run_dir.as_str()));
|
||||
if self.fs.exists(&settings_path).await? {
|
||||
return Ok(());
|
||||
}
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(format!("{}/.claude", run_dir.as_str())))
|
||||
.await?;
|
||||
self.fs
|
||||
.write(
|
||||
&settings_path,
|
||||
claude_settings_seed(project_root.as_str()).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
// Fold the plan's launch args/env into the spec, before the structured/PTY
|
||||
// split so both inherit them.
|
||||
spec.args.extend(projection.args.iter().cloned());
|
||||
spec.env.extend(projection.env.iter().cloned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensures the parent directory of `<run_dir>/<rel_path>` exists (e.g. the
|
||||
/// `.claude/` or `.codex/` subdir), so a projected file write never fails on a
|
||||
/// missing directory. A `rel_path` with no separator needs no extra dir.
|
||||
async fn ensure_run_dir_parent(
|
||||
&self,
|
||||
run_dir: &ProjectPath,
|
||||
rel_path: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some((parent, _)) = rel_path.rsplit_once(['/', '\\']) {
|
||||
if !parent.is_empty() {
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_effective_permissions(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<Option<EffectivePermissions>, AppError> {
|
||||
let Some(store) = &self.permissions else {
|
||||
return Ok(None);
|
||||
};
|
||||
let doc = store.load_permissions(project).await?;
|
||||
Ok(doc.resolve_for(agent_id))
|
||||
}
|
||||
|
||||
/// Applies the context-injection plan that must happen *before* spawn:
|
||||
/// materialising a `conventionFile` context (write the `.md` to `<cwd>/target`)
|
||||
/// or attaching the on-disk context path to an environment variable. `Args` is
|
||||
@ -1709,6 +2000,7 @@ impl LaunchAgent {
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
run_dir: &ProjectPath,
|
||||
project_root: &ProjectPath,
|
||||
runtime: Option<&McpRuntime>,
|
||||
spec: &mut SpawnSpec,
|
||||
) {
|
||||
@ -1761,16 +2053,37 @@ impl LaunchAgent {
|
||||
// écraser une déclaration réelle par la minimale.
|
||||
match runtime {
|
||||
Some(_) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
let existing = match self.fs.read(&path).await {
|
||||
Ok(bytes) => String::from_utf8(bytes).ok(),
|
||||
Err(_) => None,
|
||||
};
|
||||
let rendered = codex_config_toml(
|
||||
existing.as_deref(),
|
||||
&declaration,
|
||||
run_dir.as_str(),
|
||||
project_root.as_str(),
|
||||
);
|
||||
let _ = self.fs.write(&path, rendered.as_bytes()).await;
|
||||
}
|
||||
None => match self.fs.exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let _ = self.fs.write(&path, declaration.as_bytes()).await;
|
||||
let rendered = codex_config_toml(
|
||||
None,
|
||||
&declaration,
|
||||
run_dir.as_str(),
|
||||
project_root.as_str(),
|
||||
);
|
||||
let _ = self.fs.write(&path, rendered.as_bytes()).await;
|
||||
}
|
||||
Err(_) => {}
|
||||
},
|
||||
}
|
||||
// Permission projection (sandbox_mode/approval_policy + --sandbox/
|
||||
// --ask-for-approval) is NO LONGER done here — it is decoupled into
|
||||
// `apply_permission_projection` (lot LP3-3), so a Codex profile gets its
|
||||
// sandbox even without an MCP capability. `apply_mcp_config` is now
|
||||
// MCP-only (mcp_servers.idea table + projects trust).
|
||||
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
|
||||
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
|
||||
let home_dir = parent_dir(run_dir, target);
|
||||
@ -1986,57 +2299,9 @@ fn structured_snapshot(
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written
|
||||
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
|
||||
/// Read/Edit/Write/Bash) with the project root granted as an additional working
|
||||
/// directory (the cwd is the run dir, the agent works on the root above it), while
|
||||
/// keeping destructive/out-of-project commands denied. `project_root` is embedded
|
||||
/// verbatim; it is JSON-escaped to stay valid for unusual paths.
|
||||
///
|
||||
/// Pure (no I/O), so it is unit-testable in isolation.
|
||||
#[must_use]
|
||||
fn claude_settings_seed(project_root: &str) -> String {
|
||||
let root = json_escape(project_root);
|
||||
format!(
|
||||
r#"{{
|
||||
"permissions": {{
|
||||
"defaultMode": "bypassPermissions",
|
||||
"additionalDirectories": [
|
||||
"{root}"
|
||||
],
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash"
|
||||
],
|
||||
"deny": [
|
||||
"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*)"
|
||||
]
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"enabledMcpjsonServers": ["idea"],
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal JSON string escaper for embedding a filesystem path in the settings
|
||||
/// seed (handles the characters that actually occur in paths: backslash, quote,
|
||||
/// and control chars).
|
||||
/// Minimal JSON string escaper used by [`toml_string`] (and historically by the
|
||||
/// Claude seed, now extracted to the infra projector). Handles the characters that
|
||||
/// actually occur in paths: backslash, quote, control chars.
|
||||
fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
@ -2053,6 +2318,180 @@ fn json_escape(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn toml_string(s: &str) -> String {
|
||||
format!("\"{}\"", json_escape(s))
|
||||
}
|
||||
|
||||
/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges
|
||||
/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust
|
||||
/// entries. The permission part (`sandbox_mode` / `approval_policy` + the
|
||||
/// `--sandbox` / `--ask-for-approval` args) now lives in the Codex permission
|
||||
/// projector (`apply_permission_projection`), so this function is MCP-only and a
|
||||
/// Codex profile receives its sandbox even without an MCP capability.
|
||||
fn codex_config_toml(
|
||||
existing: Option<&str>,
|
||||
mcp_declaration: &str,
|
||||
run_dir: &str,
|
||||
project_root: &str,
|
||||
) -> String {
|
||||
let mut text = existing.unwrap_or_default().to_owned();
|
||||
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
|
||||
text = ensure_codex_trust(&text, run_dir);
|
||||
text = ensure_codex_trust(&text, project_root);
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Merges a projector's **managed** TOML fragment into an existing `config.toml`
|
||||
/// (lot LP3-3, [`ProjectedFile::MergeToml`]). Only the `managed_tables` and
|
||||
/// `managed_keys` are touched; every other line of `existing` is preserved.
|
||||
///
|
||||
/// - each managed table is replaced wholesale by its block from `fragment`
|
||||
/// ([`replace_toml_table`]);
|
||||
/// - each managed top-level key takes the value found for it in `fragment`
|
||||
/// ([`set_top_level_toml_line`]).
|
||||
///
|
||||
/// A managed table/key absent from `fragment` leaves `existing` untouched for it.
|
||||
fn merge_managed_toml(
|
||||
existing: &str,
|
||||
managed_tables: &[String],
|
||||
managed_keys: &[String],
|
||||
fragment: &str,
|
||||
) -> String {
|
||||
let mut text = existing.to_owned();
|
||||
for table in managed_tables {
|
||||
if let Some(block) = extract_toml_table(fragment, table) {
|
||||
text = replace_toml_table(&text, table, block.trim_end());
|
||||
}
|
||||
}
|
||||
for key in managed_keys {
|
||||
if let Some(line) = extract_top_level_toml_line(fragment, key) {
|
||||
text = set_top_level_toml_line(&text, key, line);
|
||||
}
|
||||
}
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Returns the full `key = …` line for `key` found at top level in `fragment`
|
||||
/// (before any `[table]` header), or `None` if absent.
|
||||
fn extract_top_level_toml_line<'a>(fragment: &'a str, key: &str) -> Option<&'a str> {
|
||||
let needle = format!("{key} =");
|
||||
for line in fragment.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with('[') {
|
||||
break;
|
||||
}
|
||||
if trimmed.starts_with(&needle) {
|
||||
return Some(line);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the `[table]` block (header + body up to the next header / EOF) for
|
||||
/// `table` in `fragment`, or `None` if the table is absent.
|
||||
fn extract_toml_table(fragment: &str, table: &str) -> Option<String> {
|
||||
let header = format!("[{table}]");
|
||||
let mut block: Vec<&str> = Vec::new();
|
||||
let mut capturing = false;
|
||||
for line in fragment.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == header {
|
||||
capturing = true;
|
||||
block.push(line);
|
||||
continue;
|
||||
}
|
||||
if capturing {
|
||||
if trimmed.starts_with('[') {
|
||||
break;
|
||||
}
|
||||
block.push(line);
|
||||
}
|
||||
}
|
||||
if capturing {
|
||||
Some(block.join("\n"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_codex_trust(input: &str, path: &str) -> String {
|
||||
let header = format!("[projects.{}]", toml_string(path));
|
||||
if input.lines().any(|line| line.trim() == header) {
|
||||
return input.to_owned();
|
||||
}
|
||||
append_block(input, &format!("{header}\ntrust_level = \"trusted\""))
|
||||
}
|
||||
|
||||
fn replace_toml_table(input: &str, table: &str, block: &str) -> String {
|
||||
let header = format!("[{table}]");
|
||||
let mut out = Vec::new();
|
||||
let mut skipping = false;
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == header {
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
if skipping && trimmed.starts_with('[') {
|
||||
skipping = false;
|
||||
}
|
||||
if !skipping {
|
||||
out.push(line.to_owned());
|
||||
}
|
||||
}
|
||||
append_block(&out.join("\n"), block)
|
||||
}
|
||||
|
||||
/// Upserts a top-level `key` with a pre-formatted `replacement` line (already
|
||||
/// `key = <toml value>`): replaces the existing top-level occurrence if present,
|
||||
/// otherwise prepends it. The line-level core shared by [`merge_managed_toml`].
|
||||
fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String {
|
||||
let mut out = Vec::new();
|
||||
let mut replaced = false;
|
||||
let mut in_top_level = true;
|
||||
for line in input.lines() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with('[') {
|
||||
in_top_level = false;
|
||||
}
|
||||
if in_top_level && !replaced && trimmed.starts_with(&format!("{key} =")) {
|
||||
out.push(replacement.to_owned());
|
||||
replaced = true;
|
||||
} else {
|
||||
out.push(line.to_owned());
|
||||
}
|
||||
}
|
||||
let text = out.join("\n");
|
||||
if replaced {
|
||||
text
|
||||
} else {
|
||||
prepend_line(&text, replacement)
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_line(input: &str, line: &str) -> String {
|
||||
if input.trim().is_empty() {
|
||||
format!("{line}\n")
|
||||
} else {
|
||||
format!("{line}\n{}", input.trim_start_matches('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
fn append_block(input: &str, block: &str) -> String {
|
||||
let trimmed = input.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
format!("{}\n", block.trim_end())
|
||||
} else {
|
||||
format!("{trimmed}\n\n{}\n", block.trim_end())
|
||||
}
|
||||
}
|
||||
|
||||
/// Composes the convention file IdeA writes into an agent's run directory: an
|
||||
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
||||
/// so it must be told where to work), the IdeA orchestration contract, the
|
||||
@ -2658,39 +3097,83 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
|
||||
let json = claude_settings_seed("/home/me/proj");
|
||||
// NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed —
|
||||
// that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
|
||||
// and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval
|
||||
// derivation moved to `CodexPermissionProjector`; `codex_config_toml` here is now
|
||||
// **MCP-only**, so the Codex tests below assert only the MCP/trust merge.
|
||||
|
||||
// Full autonomy.
|
||||
assert!(json.contains("\"defaultMode\": \"bypassPermissions\""));
|
||||
assert!(json.contains("\"Bash\""));
|
||||
// Project root granted as an additional working directory.
|
||||
assert!(json.contains("\"/home/me/proj\""));
|
||||
// Destructive guardrails preserved.
|
||||
assert!(json.contains("Bash(sudo *)"));
|
||||
assert!(json.contains("Bash(rm -rf /)"));
|
||||
assert!(json.contains("Bash(mkfs*)"));
|
||||
// Valid JSON.
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON");
|
||||
assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions");
|
||||
// IdeA MCP server pre-approved so idea_* tools load without a prompt.
|
||||
assert_eq!(parsed["enabledMcpjsonServers"][0], "idea");
|
||||
assert_eq!(
|
||||
parsed["permissions"]["additionalDirectories"][0],
|
||||
"/home/me/proj"
|
||||
#[test]
|
||||
fn codex_config_trusts_run_and_project_and_replaces_only_idea_mcp() {
|
||||
let existing = r#"[projects."/home/me/proj"]
|
||||
trust_level = "trusted"
|
||||
approval_policy = "nested"
|
||||
|
||||
[mcp_servers.idea]
|
||||
command = "old"
|
||||
|
||||
[mcp_servers.other]
|
||||
command = "other"
|
||||
"#;
|
||||
// No `permissions` argument anymore: the permission projection is decoupled
|
||||
// (lot LP3-3) into `CodexPermissionProjector`. This function only merges MCP.
|
||||
let rendered = codex_config_toml(
|
||||
Some(existing),
|
||||
"[mcp_servers.idea]\ncommand = \"new\"",
|
||||
"/home/me/proj/.ideai/run/a",
|
||||
"/home/me/proj",
|
||||
);
|
||||
|
||||
// MCP table + trust entries are the only things this function touches.
|
||||
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
|
||||
assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
|
||||
assert!(rendered.contains("approval_policy = \"nested\""));
|
||||
assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\""));
|
||||
assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\""));
|
||||
assert!(!rendered.contains("command = \"old\""));
|
||||
assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1);
|
||||
assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1);
|
||||
// Permission keys are NOT added by this MCP-only function.
|
||||
assert!(!rendered.contains("sandbox_mode ="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_settings_seed_escapes_paths_for_valid_json() {
|
||||
// A path with a backslash and a quote must not break the JSON.
|
||||
let json = claude_settings_seed(r#"/weird\path"x"#);
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&json).expect("seed with odd path is valid JSON");
|
||||
assert_eq!(
|
||||
parsed["permissions"]["additionalDirectories"][0],
|
||||
r#"/weird\path"x"#
|
||||
fn codex_config_is_mcp_only_and_adds_no_permission_keys() {
|
||||
let rendered = codex_config_toml(
|
||||
None,
|
||||
"[mcp_servers.idea]\ncommand = \"idea-mcp\"",
|
||||
"/home/me/proj/.ideai/run/a",
|
||||
"/home/me/proj",
|
||||
);
|
||||
|
||||
assert!(!rendered.contains("approval_policy ="));
|
||||
assert!(!rendered.contains("sandbox_mode ="));
|
||||
assert!(rendered.contains("[projects.\"/home/me/proj\"]"));
|
||||
assert!(rendered.contains("[projects.\"/home/me/proj/.ideai/run/a\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_managed_toml_upserts_only_managed_keys() {
|
||||
// Existing config (e.g. written by `apply_mcp_config`): MCP + trust + a user key.
|
||||
let existing = r#"user_key = "keep-me"
|
||||
|
||||
[mcp_servers.idea]
|
||||
command = "idea-mcp"
|
||||
"#;
|
||||
// Codex projector fragment: the two managed permission keys.
|
||||
let fragment = "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n";
|
||||
let merged = merge_managed_toml(
|
||||
existing,
|
||||
&[],
|
||||
&["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||
fragment,
|
||||
);
|
||||
|
||||
// Managed keys are spliced in…
|
||||
assert!(merged.contains("sandbox_mode = \"workspace-write\""));
|
||||
assert!(merged.contains("approval_policy = \"never\""));
|
||||
// …without disturbing the rest of the file.
|
||||
assert!(merged.contains("user_key = \"keep-me\""));
|
||||
assert!(merged.contains("[mcp_servers.idea]\ncommand = \"idea-mcp\""));
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,9 +24,9 @@ pub use lifecycle::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, McpRuntime, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, StructuredSessionDescriptor, UpdateAgentContext,
|
||||
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use resume::{
|
||||
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
|
||||
|
||||
@ -20,6 +20,7 @@ pub mod health;
|
||||
pub mod layout;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
@ -29,14 +30,15 @@ pub mod window;
|
||||
|
||||
pub use agent::{
|
||||
drain_with_readiness, reference_profile_id, reference_profiles, selectable_reference_profiles,
|
||||
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
|
||||
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
|
||||
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
|
||||
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
|
||||
HandoffProvider, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, ProfileAvailability,
|
||||
send_blocking, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch,
|
||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile,
|
||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,
|
||||
FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput,
|
||||
InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents,
|
||||
ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
||||
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry,
|
||||
ProfileAvailability,
|
||||
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
|
||||
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
|
||||
@ -77,6 +79,12 @@ pub use memory::{
|
||||
pub use orchestrator::{
|
||||
McpRuntimeProvider, OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
|
||||
};
|
||||
pub use permission::{
|
||||
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,
|
||||
ResolveAgentPermissions, ResolveAgentPermissionsInput, ResolveAgentPermissionsOutput,
|
||||
UpdateAgentPermissions, UpdateAgentPermissionsInput, UpdateProjectPermissions,
|
||||
UpdateProjectPermissionsInput,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
|
||||
150
crates/application/src/permission.rs
Normal file
150
crates/application/src/permission.rs
Normal file
@ -0,0 +1,150 @@
|
||||
//! Permission use cases.
|
||||
//!
|
||||
//! This module stays at the application boundary: it loads the project
|
||||
//! permission document through [`PermissionStore`], applies simple mutations, and
|
||||
//! delegates all merge semantics to the pure domain model.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::PermissionStore;
|
||||
use domain::{AgentId, EffectivePermissions, PermissionSet, Project, ProjectPermissions};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Reads the full project permission document.
|
||||
pub struct GetProjectPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl GetProjectPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: GetProjectPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let permissions = self.store.load_permissions(&input.project).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`GetProjectPermissions`].
|
||||
pub struct GetProjectPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Output for [`GetProjectPermissions`].
|
||||
pub struct GetProjectPermissionsOutput {
|
||||
/// Persisted permission document.
|
||||
pub permissions: ProjectPermissions,
|
||||
}
|
||||
|
||||
/// Replaces the project default policy.
|
||||
pub struct UpdateProjectPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl UpdateProjectPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateProjectPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let mut doc = self.store.load_permissions(&input.project).await?;
|
||||
doc.set_project_defaults(input.permissions);
|
||||
self.store.save_permissions(&input.project, &doc).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions: doc })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectPermissions`].
|
||||
pub struct UpdateProjectPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// New project default policy. `None` removes project defaults.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Replaces one agent override.
|
||||
pub struct UpdateAgentPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl UpdateAgentPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the mutation.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: UpdateAgentPermissionsInput,
|
||||
) -> Result<GetProjectPermissionsOutput, AppError> {
|
||||
let mut doc = self.store.load_permissions(&input.project).await?;
|
||||
doc.set_agent_permissions(input.agent_id, input.permissions);
|
||||
self.store.save_permissions(&input.project, &doc).await?;
|
||||
Ok(GetProjectPermissionsOutput { permissions: doc })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateAgentPermissions`].
|
||||
pub struct UpdateAgentPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
/// New agent policy. `None` removes the override.
|
||||
pub permissions: Option<PermissionSet>,
|
||||
}
|
||||
|
||||
/// Resolves effective permissions for one agent.
|
||||
pub struct ResolveAgentPermissions {
|
||||
store: Arc<dyn PermissionStore>,
|
||||
}
|
||||
|
||||
impl ResolveAgentPermissions {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn PermissionStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Executes the resolution.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ResolveAgentPermissionsInput,
|
||||
) -> Result<ResolveAgentPermissionsOutput, AppError> {
|
||||
let doc = self.store.load_permissions(&input.project).await?;
|
||||
Ok(ResolveAgentPermissionsOutput {
|
||||
effective: doc.resolve_for(input.agent_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ResolveAgentPermissions`].
|
||||
pub struct ResolveAgentPermissionsInput {
|
||||
/// Target project.
|
||||
pub project: Project,
|
||||
/// Target agent.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Output for [`ResolveAgentPermissions`].
|
||||
pub struct ResolveAgentPermissionsOutput {
|
||||
/// Resolved policy, or `None` when neither project nor agent policy exists.
|
||||
pub effective: Option<EffectivePermissions>,
|
||||
}
|
||||
@ -26,12 +26,18 @@ use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
|
||||
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
|
||||
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, SessionStrategy,
|
||||
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
|
||||
SessionStrategy, StructuredAdapter,
|
||||
};
|
||||
use domain::{PermissionSet, ProjectPermissions};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
@ -41,8 +47,8 @@ use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
|
||||
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
|
||||
LaunchAgentInput, ListAgents, ListAgentsInput, PermissionProjectorRegistry, ReadAgentContext,
|
||||
ReadAgentContextInput, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -352,6 +358,10 @@ struct FakeFs {
|
||||
/// recorded for them). Used by the MCP best-effort test to prove that an MCP
|
||||
/// config write failure never fails the launch.
|
||||
fail_writes_for: Arc<Mutex<Vec<String>>>,
|
||||
/// Canned `path → contents` returned by [`FileSystem::read`] when no later write
|
||||
/// to that path exists. Used by the `MergeToml` projection test to seed a
|
||||
/// pre-existing `.codex/config.toml` carrying unmanaged user keys.
|
||||
seeded_reads: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl FakeFs {
|
||||
@ -363,11 +373,27 @@ impl FakeFs {
|
||||
project_context: Arc::new(Mutex::new(None)),
|
||||
existing: Arc::new(Mutex::new(Vec::new())),
|
||||
fail_writes_for: Arc::new(Mutex::new(Vec::new())),
|
||||
seeded_reads: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
fn set_project_context(&self, content: &str) {
|
||||
*self.project_context.lock().unwrap() = Some(content.to_owned());
|
||||
}
|
||||
/// Seeds a canned content for `path` so [`FileSystem::read`] returns it until a
|
||||
/// later [`FileSystem::write`] supersedes it (last-write-wins).
|
||||
fn seed_read(&self, path: &str, content: &str) {
|
||||
self.seeded_reads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.to_owned(), content.to_owned());
|
||||
}
|
||||
/// All writes whose path ends with `suffix` (e.g. the projected Codex config).
|
||||
fn writes_ending_with(&self, suffix: &str) -> Vec<(String, Vec<u8>)> {
|
||||
self.writes()
|
||||
.into_iter()
|
||||
.filter(|(p, _)| p.ends_with(suffix))
|
||||
.collect()
|
||||
}
|
||||
/// Marks a path as already existing on disk, so [`FileSystem::exists`] returns
|
||||
/// `true` for it (non-clobbering scenarios).
|
||||
fn mark_existing(&self, path: &str) {
|
||||
@ -411,16 +437,26 @@ impl FakeFs {
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
if path.as_str().ends_with("/.ideai/CONTEXT.md") {
|
||||
let p = path.as_str();
|
||||
// Last-write-wins: a previously written file reads back its latest content
|
||||
// (so the MergeToml merge/idempotence test observes its own prior write).
|
||||
if let Some((_, bytes)) = self.writes().into_iter().rev().find(|(wp, _)| wp == p) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
// Then any canned seed for this exact path.
|
||||
if let Some(content) = self.seeded_reads.lock().unwrap().get(p) {
|
||||
return Ok(content.clone().into_bytes());
|
||||
}
|
||||
if p.ends_with("/.ideai/CONTEXT.md") {
|
||||
return self
|
||||
.project_context
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map(|s| s.into_bytes())
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()));
|
||||
.ok_or_else(|| FsError::NotFound(p.to_owned()));
|
||||
}
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
Err(FsError::NotFound(p.to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
let p = path.as_str();
|
||||
@ -831,17 +867,19 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
// Ordering contract. The Claude permission seed is written first (right after
|
||||
// the run dir is created), then prepare → injection (convention file) → spawn.
|
||||
// Ordering contract (lot LP3-3): prepare → injection (convention file) → spawn.
|
||||
// The permission projection no longer runs here — no projector registry is wired
|
||||
// in this fixture (`LaunchAgent::new` without `with_permission_projectors`), so no
|
||||
// extra `fs.write` precedes prepare. With a registry, the projection write would
|
||||
// appear *after* the injection write and before spawn (see `apply_permission_projection`).
|
||||
assert_eq!(
|
||||
*tr.lock().unwrap(),
|
||||
vec![
|
||||
"fs.write".to_owned(),
|
||||
"prepare".to_owned(),
|
||||
"fs.write".to_owned(),
|
||||
"spawn".to_owned()
|
||||
],
|
||||
"seed → prepare → injection → spawn"
|
||||
"prepare → injection → spawn"
|
||||
);
|
||||
|
||||
// The conventionFile was written inside the agent's isolated run directory
|
||||
@ -862,24 +900,18 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
"convention file must carry the agent persona, got: {written}"
|
||||
);
|
||||
|
||||
// Bug #5: a Claude permission seed was written into the run dir so the agent
|
||||
// runs with the project's autonomy instead of prompting per command.
|
||||
let seeds = fs.seed_writes();
|
||||
assert_eq!(seeds.len(), 1);
|
||||
assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json"));
|
||||
let seed = String::from_utf8(seeds[0].1.clone()).unwrap();
|
||||
// Lot LP3-3: the Claude permission seed is no longer written unconditionally
|
||||
// here. It is now produced by the Claude permission projector and written only
|
||||
// when a projector registry is wired (`with_permission_projectors`) — absent in
|
||||
// this fixture — so no seed is written. (Projection coverage lives in the infra
|
||||
// projector tests (LP3-2) and the LP3-3 projection wiring tests, QA.)
|
||||
assert!(
|
||||
seed.contains("bypassPermissions"),
|
||||
"seed grants autonomy: {seed}"
|
||||
);
|
||||
assert!(
|
||||
seed.contains("/home/me/proj"),
|
||||
"seed grants the project root"
|
||||
fs.seed_writes().is_empty(),
|
||||
"no projector registry wired ⇒ no permission seed written"
|
||||
);
|
||||
|
||||
// The run directory (and the seed's `.claude` subdir) were created before spawn.
|
||||
// The run directory was created before spawn.
|
||||
assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]);
|
||||
assert!(fs.created_dirs().contains(&format!("{run_dir}/.claude")));
|
||||
|
||||
// Spawn happened at the isolated run dir with the profile command.
|
||||
let spawns = pty.spawns();
|
||||
@ -2507,3 +2539,551 @@ async fn reopened_cell_does_not_load_handoff_saved_under_a_different_pair_key()
|
||||
"handoff d'une autre paire ⇒ aucune reprise (pas de fuite de clé): {doc}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LP3-3 — permission projection wiring (apply_permission_projection + registry
|
||||
// + MCP decoupling). FS-mocked, projectors are faithful test doubles (the real
|
||||
// translation is covered by the infra LP3-2 tests; application must stay
|
||||
// dependency-free of `infrastructure`).
|
||||
// ===========================================================================
|
||||
|
||||
const CLAUDE_SEED_REL: &str = "/.claude/settings.local.json";
|
||||
const CODEX_CONFIG_REL: &str = "/.codex/config.toml";
|
||||
|
||||
/// In-memory [`PermissionStore`] returning a fixed document (LP3-3 wiring tests).
|
||||
struct FakePermissionStore(ProjectPermissions);
|
||||
|
||||
#[async_trait]
|
||||
impl PermissionStore for FakePermissionStore {
|
||||
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Faithful Claude projector double: emits a single owned `Replace` settings file
|
||||
/// whose `defaultMode` mirrors the real posture→mode mapping (Allow→bypass,
|
||||
/// Ask→acceptEdits, Deny→plan), and embeds the project root verbatim. `eff == None`
|
||||
/// ⇒ empty projection.
|
||||
struct FakeClaudeProjector;
|
||||
|
||||
impl PermissionProjector for FakeClaudeProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Claude
|
||||
}
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
let Some(eff) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let mode = match eff.fallback() {
|
||||
Posture::Deny => "plan",
|
||||
Posture::Ask => "acceptEdits",
|
||||
Posture::Allow => "bypassPermissions",
|
||||
};
|
||||
let contents = format!(
|
||||
"{{\"permissions\":{{\"defaultMode\":\"{mode}\",\"additionalDirectories\":[\"{}\"]}}}}\n",
|
||||
ctx.project_root
|
||||
);
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::Replace {
|
||||
rel_path: ".claude/settings.local.json".to_owned(),
|
||||
contents,
|
||||
}],
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
vec![".claude/settings.local.json".to_owned()]
|
||||
}
|
||||
}
|
||||
|
||||
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
|
||||
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
|
||||
/// from the posture exactly like the real projector. `eff == None` ⇒ empty.
|
||||
struct FakeCodexProjector;
|
||||
|
||||
impl FakeCodexProjector {
|
||||
fn modes(fallback: Posture) -> (&'static str, &'static str) {
|
||||
match fallback {
|
||||
Posture::Deny => ("read-only", "on-request"),
|
||||
Posture::Ask => ("workspace-write", "on-request"),
|
||||
Posture::Allow => ("workspace-write", "never"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionProjector for FakeCodexProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Codex
|
||||
}
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
let Some(eff) = eff else {
|
||||
return PermissionProjection::empty();
|
||||
};
|
||||
let (sandbox, approval) = Self::modes(eff.fallback());
|
||||
let contents =
|
||||
format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: ".codex/config.toml".to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||
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> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry carrying both faithful projector doubles.
|
||||
fn full_registry() -> Arc<PermissionProjectorRegistry> {
|
||||
Arc::new(
|
||||
PermissionProjectorRegistry::new()
|
||||
.with(Arc::new(FakeClaudeProjector))
|
||||
.with(Arc::new(FakeCodexProjector)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A project document whose project-level fallback is `posture` (no agent
|
||||
/// override) ⇒ `resolve_for` yields `Some(eff)` with that fallback.
|
||||
fn perm_doc(posture: Posture) -> ProjectPermissions {
|
||||
ProjectPermissions::new(Some(PermissionSet::new(vec![], posture)), Vec::new())
|
||||
}
|
||||
|
||||
/// Builds a `codex` profile (convention `AGENTS.md`) with the given customiser, so
|
||||
/// the structured-adapter / projector / mcp variants can be exercised.
|
||||
fn codex_profile() -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
pid(9),
|
||||
"OpenAI Codex CLI",
|
||||
"codex",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||
Some("codex --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Wires a `LaunchAgent` over the fakes with an optional projector registry and an
|
||||
/// optional permission document. Returns the use case + the seeded agent + the
|
||||
/// recording fs/pty so the projection writes and folded args can be asserted.
|
||||
fn launch_with_projection(
|
||||
profile: AgentProfile,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
registry: Option<Arc<PermissionProjectorRegistry>>,
|
||||
perm_doc: Option<ProjectPermissions>,
|
||||
) -> (LaunchAgent, Agent, FakeFs, FakePty, Arc<TerminalSessions>) {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", profile.id);
|
||||
let contexts = FakeContexts::with_agent(&agent, "# ctx body");
|
||||
let profiles = FakeProfiles::new(vec![profile]);
|
||||
let tr = trace();
|
||||
let fs = FakeFs::new(Arc::clone(&tr));
|
||||
let pty = FakePty::new(Arc::clone(&tr), sid(777));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let mut launch = LaunchAgent::new(
|
||||
Arc::new(contexts),
|
||||
Arc::new(profiles),
|
||||
Arc::new(FakeRuntime::new(Arc::clone(&tr), plan)),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(FakeSkills::default()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall::default()),
|
||||
None,
|
||||
);
|
||||
if let Some(doc) = perm_doc {
|
||||
launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc)));
|
||||
}
|
||||
if let Some(reg) = registry {
|
||||
launch = launch.with_permission_projectors(reg);
|
||||
}
|
||||
(launch, agent, fs, pty, sessions)
|
||||
}
|
||||
|
||||
fn convention_file_plan() -> Option<ContextInjectionPlan> {
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- (1) projector key selection -------------------------------------------
|
||||
|
||||
/// (1a) An explicit `projector = Some(Claude)` is honoured even when the heuristic
|
||||
/// would not fire (here the convention file is GEMINI.md): the explicit field wins.
|
||||
#[tokio::test]
|
||||
async fn projection_selects_claude_from_explicit_projector_field() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "GEMINI.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
|
||||
assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)");
|
||||
assert!(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL).is_empty(),
|
||||
"the Codex projector must NOT run"
|
||||
);
|
||||
}
|
||||
|
||||
/// (1b) Legacy fallback: no `projector` field, but a `CLAUDE.md` convention file ⇒
|
||||
/// Claude projector is selected.
|
||||
#[tokio::test]
|
||||
async fn projection_falls_back_to_claude_from_convention_file() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap());
|
||||
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
assert_eq!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).len(),
|
||||
1,
|
||||
"CLAUDE.md heuristic selects the Claude projector"
|
||||
);
|
||||
}
|
||||
|
||||
/// (1c) Legacy fallback: no `projector` field, but `StructuredAdapter::Codex` ⇒
|
||||
/// Codex projector is selected.
|
||||
#[tokio::test]
|
||||
async fn projection_falls_back_to_codex_from_structured_adapter() {
|
||||
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
|
||||
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
|
||||
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
assert_eq!(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL).len(),
|
||||
1,
|
||||
"Codex structured adapter selects the Codex projector"
|
||||
);
|
||||
assert!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
||||
"the Claude projector must NOT run"
|
||||
);
|
||||
// Args were folded into the spawned spec.
|
||||
assert!(
|
||||
pty.spawns()[0].args.contains(&"--sandbox".to_owned()),
|
||||
"sandbox args folded into spec"
|
||||
);
|
||||
}
|
||||
|
||||
/// (1d) A non-projectable profile (no projector, no CLAUDE.md, no Codex signal) ⇒
|
||||
/// no projection at all, even with a full registry and a posed policy.
|
||||
#[tokio::test]
|
||||
async fn projection_noop_for_unprojectable_profile() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap());
|
||||
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "GEMINI.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty());
|
||||
assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty());
|
||||
assert!(
|
||||
!pty.spawns()[0].args.contains(&"--sandbox".to_owned()),
|
||||
"no projected args either"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (2) Replace clobber ---------------------------------------------------
|
||||
|
||||
/// (2) A `Replace` file is **clobbered** (rewritten) on every (re)launch: launching
|
||||
/// the same Claude agent twice (with the session removed in between to clear the
|
||||
/// singleton guard) writes the owned seed twice to the SAME path — proving the
|
||||
/// inversion vs. the MCP non-clobbering regime (which skips when the file exists).
|
||||
#[tokio::test]
|
||||
async fn claude_replace_seed_is_clobbered_on_relaunch() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let seed_path = format!("{run_dir}{CLAUDE_SEED_REL}");
|
||||
// Pre-mark the seed as already existing: a Replace must write anyway (clobber).
|
||||
fs.mark_existing(&seed_path);
|
||||
|
||||
// First launch, then simulate the agent exiting so a fresh relaunch is allowed.
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 1");
|
||||
sessions.remove(&sid(777));
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 2");
|
||||
|
||||
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
|
||||
assert_eq!(
|
||||
seeds.len(),
|
||||
2,
|
||||
"the owned Replace seed is rewritten on each launch (clobber), got {seeds:?}"
|
||||
);
|
||||
assert!(
|
||||
seeds.iter().all(|(p, _)| p == &seed_path),
|
||||
"both writes target the same seed path"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (3) MergeToml: upsert managed keys, preserve unmanaged, idempotent -----
|
||||
|
||||
/// (3) Projecting onto a pre-existing `.codex/config.toml` upserts the two managed
|
||||
/// keys while preserving an unmanaged user key; a second projection does not
|
||||
/// duplicate the managed keys (idempotence).
|
||||
#[tokio::test]
|
||||
async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
|
||||
let profile = codex_profile().with_projector(ProjectorKey::Codex);
|
||||
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Allow)),
|
||||
);
|
||||
|
||||
let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id);
|
||||
let cfg_path = format!("{run_dir}{CODEX_CONFIG_REL}");
|
||||
// Pre-existing config with an unmanaged top-level key + an unmanaged table.
|
||||
fs.seed_read(
|
||||
&cfg_path,
|
||||
"user_key = \"keep-me\"\n[mcp_servers.idea]\ncommand = \"idea\"\n",
|
||||
);
|
||||
|
||||
// First projection.
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 1");
|
||||
let first = String::from_utf8(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||
.last()
|
||||
.expect("a codex config write")
|
||||
.1
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}");
|
||||
assert!(first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}");
|
||||
assert!(
|
||||
first.contains("sandbox_mode = \"workspace-write\""),
|
||||
"managed sandbox_mode upserted: {first}"
|
||||
);
|
||||
assert!(
|
||||
first.contains("approval_policy = \"never\""),
|
||||
"managed approval_policy upserted: {first}"
|
||||
);
|
||||
|
||||
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
|
||||
sessions.remove(&sid(777));
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch 2");
|
||||
let second = String::from_utf8(
|
||||
fs.writes_ending_with(CODEX_CONFIG_REL)
|
||||
.last()
|
||||
.unwrap()
|
||||
.1
|
||||
.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
second.matches("sandbox_mode =").count(),
|
||||
1,
|
||||
"idempotent: no duplicate sandbox_mode after a second projection: {second}"
|
||||
);
|
||||
assert_eq!(
|
||||
second.matches("approval_policy =").count(),
|
||||
1,
|
||||
"idempotent: no duplicate approval_policy: {second}"
|
||||
);
|
||||
assert!(second.contains("user_key = \"keep-me\""), "unmanaged key still preserved");
|
||||
}
|
||||
|
||||
// ---- (4) args/env fold into the spawned spec --------------------------------
|
||||
|
||||
/// (4) The projection's launch args are folded into the spec inherited by the
|
||||
/// (PTY) spawn, in order, alongside the projected config file.
|
||||
#[tokio::test]
|
||||
async fn codex_projection_folds_args_into_spawn_spec() {
|
||||
let profile = codex_profile().with_projector(ProjectorKey::Codex);
|
||||
let (launch, agent, _fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Ask)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
let args = &pty.spawns()[0].args;
|
||||
// Ask ⇒ workspace-write / on-request, in CLI order.
|
||||
let pos = args
|
||||
.windows(2)
|
||||
.position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]);
|
||||
assert!(pos.is_some(), "expected --sandbox workspace-write in {args:?}");
|
||||
let pos2 = args
|
||||
.windows(2)
|
||||
.position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]);
|
||||
assert!(pos2.is_some(), "expected --ask-for-approval on-request in {args:?}");
|
||||
}
|
||||
|
||||
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
|
||||
|
||||
/// (5) A Codex profile with **no MCP capability** still gets its sandbox projected
|
||||
/// (config file + args). This proves the projection no longer depends on
|
||||
/// `apply_mcp_config`: the MCP table is absent, yet the permission plan is applied.
|
||||
#[tokio::test]
|
||||
async fn codex_sandbox_projected_without_any_mcp_capability() {
|
||||
let profile = codex_profile().with_projector(ProjectorKey::Codex);
|
||||
assert!(profile.mcp.is_none(), "precondition: no MCP capability");
|
||||
let (launch, agent, fs, pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "AGENTS.md".to_owned(),
|
||||
}),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
// The sandbox config WAS written despite the absent MCP capability.
|
||||
let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
|
||||
assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
|
||||
let toml = String::from_utf8(cfg[0].1.clone()).unwrap();
|
||||
assert!(toml.contains("sandbox_mode = \"read-only\""), "Deny ⇒ read-only: {toml}");
|
||||
// And the args were folded too.
|
||||
assert!(
|
||||
pty.spawns()[0]
|
||||
.args
|
||||
.windows(2)
|
||||
.any(|w| w == ["--sandbox".to_owned(), "read-only".to_owned()]),
|
||||
"sandbox args projected without MCP: {:?}",
|
||||
pty.spawns()[0].args
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (6) no-op regimes ------------------------------------------------------
|
||||
|
||||
/// (6a) Registry absent (builder never called) ⇒ no permission file is written,
|
||||
/// even with a posed policy.
|
||||
#[tokio::test]
|
||||
async fn no_registry_means_no_projection() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
None, // no registry wired
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
assert!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
||||
"no registry ⇒ no permission seed written (legacy behaviour)"
|
||||
);
|
||||
}
|
||||
|
||||
/// (6b) `eff == None` (no project/agent policy posed) ⇒ empty projection: nothing
|
||||
/// written, even though the registry IS wired.
|
||||
#[tokio::test]
|
||||
async fn no_policy_posed_means_empty_projection() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
Some(full_registry()),
|
||||
Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
assert!(
|
||||
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
|
||||
"eff == None ⇒ empty projection ⇒ no seed written"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- (7) resolved eff reflected in the projected file -----------------------
|
||||
|
||||
/// (7) Smoke: a posed `Deny` project posture flows through `resolve_for` into the
|
||||
/// projector and is reflected in the produced Claude settings (`defaultMode=plan`).
|
||||
#[tokio::test]
|
||||
async fn resolved_deny_posture_reflected_as_plan_mode() {
|
||||
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
|
||||
.with_projector(ProjectorKey::Claude);
|
||||
let (launch, agent, fs, _pty, _s) = launch_with_projection(
|
||||
profile,
|
||||
convention_file_plan(),
|
||||
Some(full_registry()),
|
||||
Some(perm_doc(Posture::Deny)),
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
|
||||
let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON");
|
||||
assert_eq!(
|
||||
json["permissions"]["defaultMode"], "plan",
|
||||
"Deny posture ⇒ plan mode: {seed}"
|
||||
);
|
||||
assert_eq!(
|
||||
json["permissions"]["additionalDirectories"][0], "/home/me/proj",
|
||||
"project root flowed into the projection ctx"
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,23 +30,30 @@ use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::layout::Workspace;
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::permission::{
|
||||
EffectivePermissions, PermissionProjection, PermissionProjector, ProjectedFile,
|
||||
ProjectionContext, ProjectorKey,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
|
||||
OutputStream, PreparedContext, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort,
|
||||
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
OutputStream, PermissionStore, PreparedContext, ProfileStore, ProjectStore, PtyError,
|
||||
PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::{
|
||||
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PtySize, SessionId,
|
||||
SessionKind, SkillId,
|
||||
LayoutId, LayoutNode, LayoutTree, LeafCell, MemoryIndexEntry, NodeId, PermissionSet, Posture,
|
||||
ProjectPermissions, PtySize, SessionId, SessionKind, SkillId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, TerminalSessions};
|
||||
use application::{
|
||||
ChangeAgentProfile, ChangeAgentProfileInput, LaunchAgent, PermissionProjectorRegistry,
|
||||
TerminalSessions,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeContexts (AgentContextStore) — manifest + md_path → content
|
||||
@ -239,6 +246,8 @@ struct FakeFsInner {
|
||||
files: HashMap<String, Vec<u8>>,
|
||||
dirs: HashSet<String>,
|
||||
write_count: usize,
|
||||
/// Paths passed to `remove_file`, in order (lot LP3-4 cleanup assertions).
|
||||
removed: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
@ -260,6 +269,14 @@ impl FakeFs {
|
||||
fn write_count(&self) -> usize {
|
||||
self.0.lock().unwrap().write_count
|
||||
}
|
||||
/// Whether a file is currently present in the fake's state.
|
||||
fn has_file(&self, path: &str) -> bool {
|
||||
self.0.lock().unwrap().files.contains_key(path)
|
||||
}
|
||||
/// The ordered list of paths passed to `remove_file` (lot LP3-4).
|
||||
fn removed(&self) -> Vec<String> {
|
||||
self.0.lock().unwrap().removed.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -287,6 +304,15 @@ impl FileSystem for FakeFs {
|
||||
self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
/// Overrides the no-op default (lot LP3-4): records the path and actually
|
||||
/// removes it from the fake's state, so a deletion is assertable. Idempotent —
|
||||
/// removing an absent file still succeeds (best-effort cleanup contract).
|
||||
async fn remove_file(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.removed.push(path.as_str().to_owned());
|
||||
inner.files.remove(path.as_str());
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@ -366,6 +392,16 @@ impl FakePty {
|
||||
fn kills(&self) -> Vec<SessionId> {
|
||||
self.kills.lock().unwrap().clone()
|
||||
}
|
||||
/// Args of the most recent spawn (used to assert folded projection args on a
|
||||
/// relaunch, lot LP3-4).
|
||||
fn last_spawn_args(&self) -> Vec<String> {
|
||||
self.spawns
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last()
|
||||
.map(|s| s.args.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -766,6 +802,492 @@ fn change_input(agent_id: AgentId, profile_id: ProfileId) -> ChangeAgentProfileI
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LP3-4 — permission-file cleanup at swap: fakes, projectors, fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// In-memory [`PermissionStore`] returning a fixed document (so the relaunch's
|
||||
/// projection resolves a non-empty `eff` and actually writes the new CLI config).
|
||||
struct FakePermissionStore(ProjectPermissions);
|
||||
|
||||
#[async_trait]
|
||||
impl PermissionStore for FakePermissionStore {
|
||||
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Faithful Claude projector double (LP3-2 mapping): owns the `Replace` settings
|
||||
/// seed `.claude/settings.local.json`.
|
||||
struct FakeClaudeProjector;
|
||||
|
||||
impl PermissionProjector for FakeClaudeProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Claude
|
||||
}
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
if eff.is_none() {
|
||||
return PermissionProjection::empty();
|
||||
}
|
||||
let contents = format!(
|
||||
"{{\"permissions\":{{\"additionalDirectories\":[\"{}\"]}}}}\n",
|
||||
ctx.project_root
|
||||
);
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::Replace {
|
||||
rel_path: ".claude/settings.local.json".to_owned(),
|
||||
contents,
|
||||
}],
|
||||
args: Vec::new(),
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
vec![".claude/settings.local.json".to_owned()]
|
||||
}
|
||||
}
|
||||
|
||||
/// Faithful Codex projector double (LP3-2 mapping): a co-owned `MergeToml` + the
|
||||
/// `--sandbox`/`--ask-for-approval` args. Owns **no** `Replace` file.
|
||||
struct FakeCodexProjector;
|
||||
|
||||
impl PermissionProjector for FakeCodexProjector {
|
||||
fn key(&self) -> ProjectorKey {
|
||||
ProjectorKey::Codex
|
||||
}
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
_ctx: &ProjectionContext,
|
||||
) -> PermissionProjection {
|
||||
if eff.is_none() {
|
||||
return PermissionProjection::empty();
|
||||
}
|
||||
PermissionProjection {
|
||||
files: vec![ProjectedFile::MergeToml {
|
||||
rel_path: ".codex/config.toml".to_owned(),
|
||||
managed_tables: Vec::new(),
|
||||
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
|
||||
contents: "sandbox_mode = \"workspace-write\"\napproval_policy = \"never\"\n"
|
||||
.to_owned(),
|
||||
}],
|
||||
args: vec![
|
||||
"--sandbox".to_owned(),
|
||||
"workspace-write".to_owned(),
|
||||
"--ask-for-approval".to_owned(),
|
||||
"never".to_owned(),
|
||||
],
|
||||
env: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn owned_replace_paths(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A registry carrying both faithful projector doubles.
|
||||
fn full_registry() -> Arc<PermissionProjectorRegistry> {
|
||||
Arc::new(
|
||||
PermissionProjectorRegistry::new()
|
||||
.with(Arc::new(FakeClaudeProjector))
|
||||
.with(Arc::new(FakeCodexProjector)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A Claude profile (convention `CLAUDE.md` ⇒ legacy Claude selection).
|
||||
fn claude_profile(id: ProfileId) -> AgentProfile {
|
||||
profile(id)
|
||||
}
|
||||
|
||||
/// A Codex profile carrying an explicit `ProjectorKey::Codex` (and the Codex
|
||||
/// structured adapter, so the legacy fallback would also select Codex).
|
||||
fn codex_profile(id: ProfileId) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
id,
|
||||
"OpenAI Codex CLI",
|
||||
"codex",
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("AGENTS.md").unwrap(),
|
||||
Some("codex --version".to_owned()),
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_projector(ProjectorKey::Codex)
|
||||
}
|
||||
|
||||
/// The (stable) run dir of `agent` under the test project root.
|
||||
fn run_dir_of(agent: &AgentId) -> String {
|
||||
format!("{ROOT}/.ideai/run/{agent}")
|
||||
}
|
||||
|
||||
/// Wires a swap fixture with a projector registry on BOTH the swap and the
|
||||
/// composed relaunch, plus an optional permission document on the relaunch (so
|
||||
/// the relaunch's projection is non-empty). Mirrors [`fixture_with_profiles`].
|
||||
async fn fixture_with_projection(
|
||||
agent: &Agent,
|
||||
profiles: Vec<AgentProfile>,
|
||||
registry: Arc<PermissionProjectorRegistry>,
|
||||
perm_doc: Option<ProjectPermissions>,
|
||||
) -> Fixture {
|
||||
let contexts = FakeContexts::with_agent(agent, "# persona");
|
||||
let profiles = FakeProfiles::new(profiles);
|
||||
let store = FakeStore::default();
|
||||
let fs = FakeFs::default();
|
||||
let pty = FakePty::new(sid(777));
|
||||
let sessions = Arc::new(TerminalSessions::new());
|
||||
let bus = SpyBus::default();
|
||||
let runtime = FakeRuntime::new();
|
||||
let handoffs = FakeHandoffs::default();
|
||||
|
||||
store.save(&project()).await;
|
||||
|
||||
let mut launch = LaunchAgent::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(profiles.clone()),
|
||||
Arc::new(runtime.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(FakeSkills),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall),
|
||||
None,
|
||||
)
|
||||
.with_handoff_provider(Arc::new(handoffs.clone()))
|
||||
.with_permission_projectors(Arc::clone(®istry));
|
||||
if let Some(doc) = perm_doc {
|
||||
launch = launch.with_permission_store(Arc::new(FakePermissionStore(doc)));
|
||||
}
|
||||
|
||||
let swap = ChangeAgentProfile::new(
|
||||
Arc::new(contexts.clone()),
|
||||
Arc::new(profiles),
|
||||
Arc::new(store),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::clone(&sessions),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(launch),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.with_permission_projectors(Arc::clone(®istry));
|
||||
|
||||
Fixture {
|
||||
swap,
|
||||
contexts,
|
||||
fs,
|
||||
pty,
|
||||
bus,
|
||||
sessions,
|
||||
runtime,
|
||||
handoffs,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full `permissions.json` posing a project-level `Allow` policy ⇒ a relaunch
|
||||
/// resolves `Some(eff)` and the projector writes the new CLI config.
|
||||
fn allow_perm_doc() -> ProjectPermissions {
|
||||
ProjectPermissions::new(Some(PermissionSet::new(vec![], Posture::Allow)), Vec::new())
|
||||
}
|
||||
|
||||
const CLAUDE_SEED_REL: &str = ".claude/settings.local.json";
|
||||
const CODEX_CONFIG_REL: &str = ".codex/config.toml";
|
||||
|
||||
/// Seeds a live agent session + a persisted layout cell hosting it, so the swap
|
||||
/// reaches its relaunch step (kill → relaunch in the same cell).
|
||||
fn seed_live_for_relaunch(f: &Fixture, agent: &AgentId) {
|
||||
let host = nid(1);
|
||||
seed_live_agent_session(&f.sessions, *agent, host, sid(42));
|
||||
let tree = LayoutTree::single(agent_leaf(host, Some(*agent), Some(&pair_uuid()), true));
|
||||
seed_layouts(&f.fs, lid(1), &tree);
|
||||
}
|
||||
|
||||
/// A stable UUID-shaped pair id used by the relaunch scenarios.
|
||||
fn pair_uuid() -> String {
|
||||
Uuid::from_u128(0xABCD).to_string()
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LP3-4 — cleanup of orphan permission files at a cross-profile swap
|
||||
// ===========================================================================
|
||||
|
||||
/// (1) **Claude→Codex (phare)**: the pre-existing `.claude/settings.local.json`
|
||||
/// is removed at the swap, and the relaunch projects the Codex sandbox config +
|
||||
/// args. The orphan Replace file of the old projector is cleaned up; the new
|
||||
/// projector (Codex) owns no Replace file, so nothing protects it from deletion.
|
||||
#[tokio::test]
|
||||
async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![claude_profile(pid(1)), codex_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let run_dir = run_dir_of(&agent.id);
|
||||
let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
|
||||
let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
|
||||
// The old Claude seed exists in the (stable) run dir before the swap.
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
|
||||
seed_live_for_relaunch(&f, &agent.id);
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
// The orphan Claude seed was deleted (recorded AND gone from the FS state).
|
||||
assert!(
|
||||
f.fs.removed().iter().any(|p| p == &seed_path),
|
||||
"the orphan .claude seed must be removed; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state");
|
||||
|
||||
// The relaunch projected the Codex sandbox config + args.
|
||||
assert!(
|
||||
f.fs.has_file(&codex_path),
|
||||
"the relaunch must project the Codex config"
|
||||
);
|
||||
let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap();
|
||||
assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}");
|
||||
assert!(
|
||||
f.pty.last_spawn_args().contains(&"--sandbox".to_owned()),
|
||||
"sandbox args folded into the relaunch spawn: {:?}",
|
||||
f.pty.last_spawn_args()
|
||||
);
|
||||
}
|
||||
|
||||
/// (2) **Claude→Claude**: `owned(old) − owned(new)` is empty, so the seed is NOT
|
||||
/// removed (it is re-clobbered by the relaunch, never deleted).
|
||||
#[tokio::test]
|
||||
async fn swap_claude_to_claude_does_not_remove_seed() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![claude_profile(pid(1)), claude_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
|
||||
seed_live_for_relaunch(&f, &agent.id);
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
assert!(
|
||||
!f.fs.removed().iter().any(|p| p == &seed_path),
|
||||
"same-family swap must NOT delete the shared seed; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
// It is still present (re-clobbered by the relaunch's projection).
|
||||
assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap");
|
||||
}
|
||||
|
||||
/// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the
|
||||
/// cleanup side; the relaunch writes the Claude seed; the co-owned
|
||||
/// `.codex/config.toml` is never deleted.
|
||||
#[tokio::test]
|
||||
async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![codex_profile(pid(1)), claude_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let run_dir = run_dir_of(&agent.id);
|
||||
let codex_path = format!("{run_dir}/{CODEX_CONFIG_REL}");
|
||||
let seed_path = format!("{run_dir}/{CLAUDE_SEED_REL}");
|
||||
// A pre-existing co-owned Codex config that must survive the swap.
|
||||
f.fs.put(&codex_path, b"sandbox_mode = \"read-only\"\n");
|
||||
|
||||
seed_live_for_relaunch(&f, &agent.id);
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
// Nothing removed (Codex has no Replace-owned path).
|
||||
assert!(
|
||||
f.fs.removed().is_empty(),
|
||||
"Codex→Claude removes no permission file; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
// The co-owned Codex config is untouched by cleanup…
|
||||
assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted");
|
||||
assert!(
|
||||
!f.fs.removed().iter().any(|p| p == &codex_path),
|
||||
"the .codex/config.toml is not in the removed list"
|
||||
);
|
||||
// …and the relaunch projected the new Claude seed.
|
||||
assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed");
|
||||
}
|
||||
|
||||
/// (4a) **No-op (no registry)**: without a projector registry wired on the swap,
|
||||
/// no cleanup runs at all — even on a Claude→Codex swap with the seed present.
|
||||
#[tokio::test]
|
||||
async fn swap_without_registry_removes_nothing() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
// The default fixture wires NO projector registry on the swap.
|
||||
let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await;
|
||||
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
assert!(
|
||||
f.fs.removed().is_empty(),
|
||||
"no registry ⇒ no cleanup; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
assert!(f.fs.has_file(&seed_path), "the seed is left untouched");
|
||||
}
|
||||
|
||||
/// (4b) **No-op (previous profile deleted)**: if the old profile id is no longer
|
||||
/// in the store, the cleanup is skipped (the old projector can't be resolved) and
|
||||
/// the swap still succeeds.
|
||||
#[tokio::test]
|
||||
async fn swap_with_unknown_previous_profile_skips_cleanup() {
|
||||
// Agent records pid(1), but the store only knows pid(2) (the target) and pid(3):
|
||||
// pid(1) was deleted between launch and swap.
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![codex_profile(pid(2)), claude_profile(pid(3))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
f.fs.put(&seed_path, b"{\"permissions\":{}}");
|
||||
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds even with an unknown previous profile");
|
||||
|
||||
assert!(
|
||||
f.fs.removed().is_empty(),
|
||||
"unknown previous profile ⇒ cleanup skipped; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
}
|
||||
|
||||
/// (5) **Best-effort**: when the orphan seed file does NOT exist on disk, the
|
||||
/// delete is still attempted (idempotent) and the swap succeeds without error.
|
||||
#[tokio::test]
|
||||
async fn swap_claude_to_codex_succeeds_when_seed_absent() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![claude_profile(pid(1)), codex_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Intentionally do NOT seed `.claude/settings.local.json`.
|
||||
let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id));
|
||||
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds even when the orphan file is already gone");
|
||||
|
||||
// The delete was attempted (best-effort, idempotent) on the missing path.
|
||||
assert!(
|
||||
f.fs.removed().iter().any(|p| p == &seed_path),
|
||||
"a remove was still attempted on the absent seed; removed={:?}",
|
||||
f.fs.removed()
|
||||
);
|
||||
}
|
||||
|
||||
/// (6) **Non-regression P8d**: the cleanup must not disturb the preserved pair id.
|
||||
/// A live Claude→Codex swap with a handoff seeded under the leaf's pair id still
|
||||
/// re-injects that handoff into the new engine's convention file (proving the
|
||||
/// pair `conversation_id` survived the cleanup step unchanged).
|
||||
#[tokio::test]
|
||||
async fn swap_with_cleanup_preserves_pair_id_and_handoff() {
|
||||
let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1));
|
||||
let f = fixture_with_projection(
|
||||
&agent,
|
||||
vec![claude_profile(pid(1)), codex_profile(pid(2))],
|
||||
full_registry(),
|
||||
Some(allow_perm_doc()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Seed the orphan Claude seed (so cleanup actually fires) and a handoff under
|
||||
// the leaf's pair id.
|
||||
let run_dir = run_dir_of(&agent.id);
|
||||
f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}");
|
||||
let pair = pair_uuid();
|
||||
f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif"));
|
||||
|
||||
let host = nid(1);
|
||||
seed_live_agent_session(&f.sessions, agent.id, host, sid(42));
|
||||
let tree = LayoutTree::single(agent_leaf(host, Some(agent.id), Some(&pair), true));
|
||||
seed_layouts(&f.fs, lid(1), &tree);
|
||||
|
||||
f.swap
|
||||
.execute(change_input(agent.id, pid(2)))
|
||||
.await
|
||||
.expect("swap succeeds");
|
||||
|
||||
// The cleanup fired…
|
||||
assert!(
|
||||
f.fs.removed()
|
||||
.iter()
|
||||
.any(|p| p == &format!("{run_dir}/{CLAUDE_SEED_REL}")),
|
||||
"the orphan seed was cleaned up"
|
||||
);
|
||||
// …and the pair id was preserved on the persisted leaf (P8d invariant).
|
||||
let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted");
|
||||
assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved");
|
||||
assert!(!running, "engine running flag reset by invalidate_engine_link");
|
||||
|
||||
// …and the handoff (keyed by that pair id) was re-injected into the new engine's
|
||||
// convention file — proving the relaunch threaded the preserved pair id. (The
|
||||
// shared FakeRuntime always materialises the convention file as `CLAUDE.md`.)
|
||||
let conv_md = String::from_utf8(
|
||||
f.fs.read_file(&format!("{run_dir}/CLAUDE.md"))
|
||||
.expect("the relaunch wrote the new engine's convention file"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
conv_md.contains("LP3-4"),
|
||||
"handoff re-injected under the preserved pair id: {conv_md}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
128
crates/application/tests/permission_usecases.rs
Normal file
128
crates/application/tests/permission_usecases.rs
Normal file
@ -0,0 +1,128 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{
|
||||
ResolveAgentPermissions, ResolveAgentPermissionsInput, UpdateAgentPermissions,
|
||||
UpdateAgentPermissionsInput, UpdateProjectPermissions, UpdateProjectPermissionsInput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::ids::{AgentId, ProjectId};
|
||||
use domain::ports::{PermissionStore, StoreError};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{PermissionSet, Posture, ProjectPermissions};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakePermissionStore {
|
||||
doc: Mutex<ProjectPermissions>,
|
||||
saves: Mutex<usize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PermissionStore for FakePermissionStore {
|
||||
async fn load_permissions(&self, _project: &Project) -> Result<ProjectPermissions, StoreError> {
|
||||
Ok(self.doc.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save_permissions(
|
||||
&self,
|
||||
_project: &Project,
|
||||
permissions: &ProjectPermissions,
|
||||
) -> Result<(), StoreError> {
|
||||
*self.doc.lock().unwrap() = permissions.clone();
|
||||
*self.saves.lock().unwrap() += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::new_random(),
|
||||
"permissions",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_project_permissions_replaces_defaults_and_persists() {
|
||||
let store = Arc::new(FakePermissionStore::default());
|
||||
let use_case = UpdateProjectPermissions::new(store.clone());
|
||||
|
||||
let out = use_case
|
||||
.execute(UpdateProjectPermissionsInput {
|
||||
project: project(),
|
||||
permissions: Some(PermissionSet::new(vec![], Posture::Ask)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
out.permissions.project_defaults.unwrap().fallback(),
|
||||
Posture::Ask
|
||||
);
|
||||
assert_eq!(*store.saves.lock().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_agent_permissions_adds_and_removes_sparse_override() {
|
||||
let store = Arc::new(FakePermissionStore::default());
|
||||
let use_case = UpdateAgentPermissions::new(store.clone());
|
||||
let agent = AgentId::new_random();
|
||||
|
||||
use_case
|
||||
.execute(UpdateAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
permissions: Some(PermissionSet::new(vec![], Posture::Deny)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.doc
|
||||
.lock()
|
||||
.unwrap()
|
||||
.agent_permissions(agent)
|
||||
.unwrap()
|
||||
.fallback(),
|
||||
Posture::Deny
|
||||
);
|
||||
|
||||
let out = use_case
|
||||
.execute(UpdateAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
permissions: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.permissions.agent_permissions(agent).is_none());
|
||||
assert_eq!(*store.saves.lock().unwrap(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_agent_permissions_returns_effective_policy() {
|
||||
let agent = AgentId::new_random();
|
||||
let store = Arc::new(FakePermissionStore {
|
||||
doc: Mutex::new(ProjectPermissions::new(
|
||||
Some(PermissionSet::new(vec![], Posture::Ask)),
|
||||
vec![],
|
||||
)),
|
||||
saves: Mutex::new(0),
|
||||
});
|
||||
let use_case = ResolveAgentPermissions::new(store);
|
||||
|
||||
let out = use_case
|
||||
.execute(ResolveAgentPermissionsInput {
|
||||
project: project(),
|
||||
agent_id: agent,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.effective.unwrap().fallback(), Posture::Ask);
|
||||
}
|
||||
Reference in New Issue
Block a user