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:
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user