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:
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user