merge(application): writable roots Codex corrects sur projets non-IdeA — #309 (QA verte)
intègre fix/309-codex-writable-roots commit 1f38c61 — politique de lancement Codex unifiée: project root par défaut sans permissions explicites, roots extra posés uniquement en workspace-write, réécriture --sandbox/--add-dir du SpawnSpec PTY par apply_codex_pty_launch_policy (composition identique first-run/relaunch/custom CLI/headless) ; QA verte reconfirmée par Git: fmt --check, diff --check, codex_permissions 3/3, lifecycle 48/48 ; fichiers .ideai/*, attachments/ et scripts/ hors périmètre préservés en worktree ; aucun push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -37,9 +37,7 @@ use domain::{
|
|||||||
|
|
||||||
use domain::live_state::WorkStatus;
|
use domain::live_state::WorkStatus;
|
||||||
|
|
||||||
use crate::codex_permissions::{
|
use crate::codex_permissions::{codex_permission_writable_roots, compile_codex_launch_policy};
|
||||||
codex_permission_writable_roots, codex_sandbox_mode, compile_codex_launch_policy,
|
|
||||||
};
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::layout::{persist_doc, resolve_doc};
|
use crate::layout::{persist_doc, resolve_doc};
|
||||||
use crate::managed_temp::{
|
use crate::managed_temp::{
|
||||||
@ -1649,19 +1647,56 @@ fn append_codex_pty_model_overrides(profile: &AgentProfile, spec: &mut SpawnSpec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_codex_pty_managed_temp_roots(
|
fn apply_codex_pty_launch_policy(
|
||||||
profile: &AgentProfile,
|
profile: &AgentProfile,
|
||||||
permissions: Option<&EffectivePermissions>,
|
permissions: Option<&EffectivePermissions>,
|
||||||
|
network: Option<NetworkPolicy>,
|
||||||
|
project_root: &str,
|
||||||
|
run_dir: &str,
|
||||||
managed_temp_roots: &[String],
|
managed_temp_roots: &[String],
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) {
|
) {
|
||||||
if managed_temp_roots.is_empty() || !is_codex_profile(profile) {
|
if !is_codex_profile(profile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if codex_sandbox_mode(permissions, managed_temp_roots) != "workspace-write" {
|
|
||||||
return;
|
let StructuredProviderLaunchPolicy::Codex {
|
||||||
|
sandbox_mode,
|
||||||
|
writable_roots,
|
||||||
|
..
|
||||||
|
} = compile_codex_launch_policy(
|
||||||
|
project_root,
|
||||||
|
run_dir,
|
||||||
|
permissions,
|
||||||
|
network,
|
||||||
|
managed_temp_roots,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut index = 0;
|
||||||
|
let mut sandbox_seen = false;
|
||||||
|
while index < spec.args.len() {
|
||||||
|
if spec.args[index] == "--sandbox" && index + 1 < spec.args.len() {
|
||||||
|
spec.args[index + 1] = sandbox_mode.clone();
|
||||||
|
sandbox_seen = true;
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if spec.args[index] == "--add-dir"
|
||||||
|
&& spec
|
||||||
|
.args
|
||||||
|
.get(index + 1)
|
||||||
|
.is_some_and(|root| root == project_root)
|
||||||
|
{
|
||||||
|
spec.args.drain(index..=index + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
}
|
}
|
||||||
for root in managed_temp_roots {
|
if !sandbox_seen {
|
||||||
|
spec.args.push("--sandbox".to_owned());
|
||||||
|
spec.args.push(sandbox_mode);
|
||||||
|
}
|
||||||
|
for root in writable_roots {
|
||||||
if spec
|
if spec
|
||||||
.args
|
.args
|
||||||
.windows(2)
|
.windows(2)
|
||||||
@ -1670,7 +1705,7 @@ fn append_codex_pty_managed_temp_roots(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
spec.args.push("--add-dir".to_owned());
|
spec.args.push("--add-dir".to_owned());
|
||||||
spec.args.push(root.clone());
|
spec.args.push(root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2626,9 +2661,12 @@ impl LaunchAgent {
|
|||||||
spec.sandbox =
|
spec.sandbox =
|
||||||
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
|
enrich_sandbox_plan_with_managed_temp_roots(spec.sandbox, &managed_temp_writable_roots);
|
||||||
spec.sandbox = enrich_sandbox_plan_with_plugin_mcp_roots(spec.sandbox, &plugin_mcp_servers);
|
spec.sandbox = enrich_sandbox_plan_with_plugin_mcp_roots(spec.sandbox, &plugin_mcp_servers);
|
||||||
append_codex_pty_managed_temp_roots(
|
apply_codex_pty_launch_policy(
|
||||||
&profile,
|
&profile,
|
||||||
effective_permissions.as_ref(),
|
effective_permissions.as_ref(),
|
||||||
|
network_permission,
|
||||||
|
input.project.root.as_str(),
|
||||||
|
run_dir.as_str(),
|
||||||
&managed_temp_writable_roots,
|
&managed_temp_writable_roots,
|
||||||
&mut spec,
|
&mut spec,
|
||||||
);
|
);
|
||||||
@ -4630,6 +4668,95 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::Skill;
|
use domain::Skill;
|
||||||
|
|
||||||
|
fn codex_profile() -> AgentProfile {
|
||||||
|
crate::agent::reference_profiles()
|
||||||
|
.into_iter()
|
||||||
|
.find(|profile| profile.projector == Some(ProjectorKey::Codex))
|
||||||
|
.expect("reference Codex profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_spec(args: Vec<&str>) -> SpawnSpec {
|
||||||
|
SpawnSpec {
|
||||||
|
command: "codex".to_owned(),
|
||||||
|
args: args.into_iter().map(str::to_owned).collect(),
|
||||||
|
cwd: ProjectPath::new("/project/.ideai/run/main").unwrap(),
|
||||||
|
env: Vec::new(),
|
||||||
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_pty_first_launch_pairs_workspace_mode_with_project_and_temp_roots() {
|
||||||
|
let mut spec = spawn_spec(Vec::new());
|
||||||
|
apply_codex_pty_launch_policy(
|
||||||
|
&codex_profile(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"/project",
|
||||||
|
"/project/.ideai/run/main",
|
||||||
|
&["/managed/tmp".to_owned(), "/project".to_owned()],
|
||||||
|
&mut spec,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(spec
|
||||||
|
.args
|
||||||
|
.windows(2)
|
||||||
|
.any(|pair| pair == ["--sandbox", "workspace-write"]));
|
||||||
|
assert_eq!(
|
||||||
|
spec.args
|
||||||
|
.windows(2)
|
||||||
|
.filter(|pair| pair == &["--add-dir", "/project"])
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spec.args
|
||||||
|
.windows(2)
|
||||||
|
.filter(|pair| pair == &["--add-dir", "/managed/tmp"])
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_pty_deny_replaces_advisory_workspace_root_without_temp_escape() {
|
||||||
|
use domain::permission::{
|
||||||
|
resolve, Capability, Effect, PathScope, PermissionRule, PermissionSet, Posture,
|
||||||
|
};
|
||||||
|
|
||||||
|
let deny = PermissionRule::file(
|
||||||
|
Capability::Delete,
|
||||||
|
Effect::Deny,
|
||||||
|
PathScope::new(["**".to_owned()]).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let permissions =
|
||||||
|
resolve(Some(&PermissionSet::new(vec![deny], Posture::Ask)), None).unwrap();
|
||||||
|
let mut spec = spawn_spec(vec![
|
||||||
|
"--sandbox",
|
||||||
|
"workspace-write",
|
||||||
|
"--add-dir",
|
||||||
|
"/project",
|
||||||
|
]);
|
||||||
|
|
||||||
|
apply_codex_pty_launch_policy(
|
||||||
|
&codex_profile(),
|
||||||
|
Some(&permissions),
|
||||||
|
None,
|
||||||
|
"/project",
|
||||||
|
"/project/.ideai/run/main",
|
||||||
|
&["/managed/tmp".to_owned()],
|
||||||
|
&mut spec,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(spec
|
||||||
|
.args
|
||||||
|
.windows(2)
|
||||||
|
.any(|pair| pair == ["--sandbox", "read-only"]));
|
||||||
|
assert!(!spec.args.iter().any(|arg| arg == "--add-dir"));
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn compose_convention_file(
|
fn compose_convention_file(
|
||||||
project_root: &str,
|
project_root: &str,
|
||||||
|
|||||||
@ -11,9 +11,10 @@ pub(crate) fn compile_codex_launch_policy(
|
|||||||
extra_writable_roots: &[String],
|
extra_writable_roots: &[String],
|
||||||
) -> StructuredProviderLaunchPolicy {
|
) -> StructuredProviderLaunchPolicy {
|
||||||
let mut writable_roots = codex_permission_writable_roots(project_root, run_dir, permissions);
|
let mut writable_roots = codex_permission_writable_roots(project_root, run_dir, permissions);
|
||||||
append_unique_paths(&mut writable_roots, extra_writable_roots);
|
|
||||||
let sandbox_mode = codex_sandbox_mode(permissions, &writable_roots).to_owned();
|
let sandbox_mode = codex_sandbox_mode(permissions, &writable_roots).to_owned();
|
||||||
if sandbox_mode != "workspace-write" {
|
if sandbox_mode == "workspace-write" {
|
||||||
|
append_unique_paths(&mut writable_roots, extra_writable_roots);
|
||||||
|
} else {
|
||||||
writable_roots.clear();
|
writable_roots.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,7 +31,11 @@ pub(crate) fn codex_permission_writable_roots(
|
|||||||
permissions: Option<&EffectivePermissions>,
|
permissions: Option<&EffectivePermissions>,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let Some(permissions) = permissions else {
|
let Some(permissions) = permissions else {
|
||||||
return Vec::new();
|
return if project_root.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![project_root.to_owned()]
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
@ -99,3 +104,75 @@ fn append_unique_path(target: &mut Vec<String>, root: &str) {
|
|||||||
}
|
}
|
||||||
target.push(root.to_owned());
|
target.push(root.to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::permission::{resolve, PathScope, PermissionRule, PermissionSet};
|
||||||
|
|
||||||
|
fn effective(rules: Vec<PermissionRule>, fallback: Posture) -> EffectivePermissions {
|
||||||
|
resolve(Some(&PermissionSet::new(rules, fallback)), None).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex(policy: StructuredProviderLaunchPolicy) -> (String, Vec<String>) {
|
||||||
|
let StructuredProviderLaunchPolicy::Codex {
|
||||||
|
sandbox_mode,
|
||||||
|
writable_roots,
|
||||||
|
..
|
||||||
|
} = policy;
|
||||||
|
(sandbox_mode, writable_roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unconfigured_policy_writes_project_and_deduplicated_managed_temp() {
|
||||||
|
let (mode, roots) = codex(compile_codex_launch_policy(
|
||||||
|
"/project",
|
||||||
|
"/project/.ideai/run/main",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&["/managed/tmp".to_owned(), "/project".to_owned()],
|
||||||
|
));
|
||||||
|
assert_eq!(mode, "workspace-write");
|
||||||
|
assert_eq!(roots, vec!["/project", "/managed/tmp"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_write_deny_never_gains_project_or_managed_temp_root() {
|
||||||
|
let deny = PermissionRule::file(
|
||||||
|
Capability::Write,
|
||||||
|
Effect::Deny,
|
||||||
|
PathScope::new(["**".to_owned()]).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let permissions = effective(vec![deny], Posture::Ask);
|
||||||
|
let (mode, roots) = codex(compile_codex_launch_policy(
|
||||||
|
"/project",
|
||||||
|
"/run",
|
||||||
|
Some(&permissions),
|
||||||
|
None,
|
||||||
|
&["/managed/tmp".to_owned()],
|
||||||
|
));
|
||||||
|
assert_eq!(mode, "read-only");
|
||||||
|
assert!(roots.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_allow_exposes_only_its_computed_rw_grant() {
|
||||||
|
let allow = PermissionRule::file(
|
||||||
|
Capability::Write,
|
||||||
|
Effect::Allow,
|
||||||
|
PathScope::new(["generated/**".to_owned()]).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let permissions = effective(vec![allow], Posture::Deny);
|
||||||
|
let (mode, roots) = codex(compile_codex_launch_policy(
|
||||||
|
"/project",
|
||||||
|
"/run",
|
||||||
|
Some(&permissions),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
));
|
||||||
|
assert_eq!(mode, "workspace-write");
|
||||||
|
assert_eq!(roots, vec!["/project/generated"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -4342,9 +4342,16 @@ async fn codex_network_env_is_projected_without_filesystem_permission_policy() {
|
|||||||
.contains("[sandbox_workspace_write]\nnetwork_access = true")),
|
.contains("[sandbox_workspace_write]\nnetwork_access = true")),
|
||||||
"network policy must still update Codex config without filesystem/bash policy"
|
"network policy must still update Codex config without filesystem/bash policy"
|
||||||
);
|
);
|
||||||
|
let args = &pty.spawns()[0].args;
|
||||||
assert!(
|
assert!(
|
||||||
!pty.spawns()[0].args.contains(&"--sandbox".to_owned()),
|
args.windows(2)
|
||||||
"no filesystem/bash policy means no Codex sandbox args are folded"
|
.any(|pair| pair == ["--sandbox", "workspace-write"]),
|
||||||
|
"Codex runs from its isolated run dir, so an unconfigured project still needs an explicit workspace-write mode: {args:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
args.windows(2)
|
||||||
|
.any(|pair| pair == ["--add-dir", "/home/me/proj"]),
|
||||||
|
"the real project root must be writable on the first PTY spawn: {args:?}"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pty.spawns()[0]
|
pty.spawns()[0]
|
||||||
@ -4396,6 +4403,15 @@ async fn codex_projection_forwards_network_env_to_structured_session_factory() {
|
|||||||
"structured launch output is populated"
|
"structured launch output is populated"
|
||||||
);
|
);
|
||||||
assert_eq!(factory.envs().len(), 1, "exactly one structured launch");
|
assert_eq!(factory.envs().len(), 1, "exactly one structured launch");
|
||||||
|
assert_eq!(
|
||||||
|
factory.policies(),
|
||||||
|
vec![Some(domain::ports::StructuredProviderLaunchPolicy::Codex {
|
||||||
|
sandbox_mode: "workspace-write".to_owned(),
|
||||||
|
writable_roots: vec!["/home/me/proj".to_owned()],
|
||||||
|
network_access: true,
|
||||||
|
})],
|
||||||
|
"unconfigured structured Codex must receive the project root because cwd is the run dir"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
factory.envs()[0]
|
factory.envs()[0]
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
Reference in New Issue
Block a user