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

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

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

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

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

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

View File

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

View File

@ -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(&registry));
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(&registry));
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
// ---------------------------------------------------------------------------

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