feat: implémentation et QA ticket99 - agent model configuration v2

- backend A-C: VO Codex/Claude, renderers/projecteurs modèle, SecretRef/env, catalogues/use cases/Tauri commands
- frontend D: profils Codex/Claude provider->model->secret, validations, conservation SecretRef
- fix: app-tauri embedded_server isolant IDEA_WEB_ROOT

QA: backend 57/57 tests, frontend 74/74 tests OK
This commit is contained in:
2026-07-26 11:56:25 +02:00
parent 13fb538880
commit dae07d35bb
27 changed files with 2358 additions and 143 deletions

View File

@ -30,7 +30,7 @@ const CONFIG_REL_PATH: &str = ".codex/config.toml";
/// The top-level keys this projector manages in `config.toml`. Everything else
/// (MCP table, trust entries, user keys) is preserved by the merge.
const MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
const PERMISSION_MANAGED_KEYS: [&str; 2] = ["sandbox_mode", "approval_policy"];
/// Codex workspace-write sandbox table owned by IdeA for network projection.
const SANDBOX_WORKSPACE_WRITE_TABLE: &str = "sandbox_workspace_write";
@ -57,7 +57,7 @@ impl PermissionProjector for CodexPermissionProjector {
// orthogonal and still gets an explicit env override to avoid stale inheritance.
let Some(permissions) = eff else {
return PermissionProjection {
files: vec![codex_network_file(network)],
files: vec![codex_model_and_network_file(ctx, network)],
env: codex_network_env(network),
..PermissionProjection::empty()
};
@ -69,13 +69,14 @@ impl PermissionProjector for CodexPermissionProjector {
// Permission-only TOML fragment (escaped exactly like the former
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
// permission concern and stay with the MCP wiring (LP3-3).
let contents = format!(
let mut contents = format!(
"sandbox_mode = {}\napproval_policy = {}\n\n[{}]\nnetwork_access = {}\n",
toml_string(sandbox),
toml_string(approval),
SANDBOX_WORKSPACE_WRITE_TABLE,
network_access,
);
append_codex_model_config(&mut contents, ctx);
let mut args = vec![
"--sandbox".to_owned(),
@ -91,8 +92,8 @@ impl PermissionProjector for CodexPermissionProjector {
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(),
managed_tables: vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()],
managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(),
managed_tables: codex_managed_tables(ctx),
managed_keys: codex_managed_keys(ctx, true),
contents,
}],
args,
@ -106,19 +107,77 @@ impl PermissionProjector for CodexPermissionProjector {
}
}
fn codex_network_file(network: Option<NetworkPolicy>) -> ProjectedFile {
fn codex_model_and_network_file(
ctx: &ProjectionContext,
network: Option<NetworkPolicy>,
) -> ProjectedFile {
let mut contents = format!(
"[{}]\nnetwork_access = {}\n",
SANDBOX_WORKSPACE_WRITE_TABLE,
codex_network_access(network),
);
append_codex_model_config(&mut contents, ctx);
ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(),
managed_tables: vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()],
managed_keys: Vec::new(),
contents: format!(
"[{}]\nnetwork_access = {}\n",
SANDBOX_WORKSPACE_WRITE_TABLE,
codex_network_access(network),
),
managed_tables: codex_managed_tables(ctx),
managed_keys: codex_managed_keys(ctx, false),
contents,
}
}
fn codex_managed_keys(ctx: &ProjectionContext, include_permissions: bool) -> Vec<String> {
let mut keys = Vec::new();
if include_permissions {
keys.extend(PERMISSION_MANAGED_KEYS.iter().map(|k| (*k).to_owned()));
}
if ctx.model.is_some() {
keys.push("model".to_owned());
}
if ctx.model_provider.is_some() {
keys.push("model_provider".to_owned());
}
keys
}
fn codex_managed_tables(ctx: &ProjectionContext) -> Vec<String> {
let mut tables = vec![SANDBOX_WORKSPACE_WRITE_TABLE.to_owned()];
if let Some(provider) = ctx.model_provider {
if ctx.model_provider_base_url.is_some() {
tables.push(codex_model_provider_table(provider));
}
}
tables
}
fn append_codex_model_config(contents: &mut String, ctx: &ProjectionContext) {
if let Some(model) = ctx.model {
contents.push_str(&format!("model = {}\n", toml_string(model)));
}
let Some(provider) = ctx.model_provider else {
return;
};
contents.push_str(&format!("model_provider = {}\n", toml_string(provider)));
if let Some(base_url) = ctx.model_provider_base_url {
let table = codex_model_provider_table(provider);
let name = provider_display_name(ctx);
let env_key = ctx.model_provider_env_key.unwrap_or("OPENAI_API_KEY");
contents.push_str(&format!(
"\n[{table}]\nname = {}\nbase_url = {}\nenv_key = {}\nwire_api = \"responses\"\n",
toml_string(name),
toml_string(base_url),
toml_string(env_key),
));
}
}
fn provider_display_name<'a>(ctx: &'a ProjectionContext<'a>) -> &'a str {
ctx.model_provider.unwrap_or("custom")
}
fn codex_model_provider_table(provider: &str) -> String {
format!("model_providers.{}", toml_string(provider))
}
fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> {
// Codex inherits the parent environment by default. Always set the variable for
// Codex launches so a stale `CODEX_SANDBOX_NETWORK_DISABLED=1` in IdeA's own
@ -161,6 +220,10 @@ mod tests {
ProjectionContext {
project_root: "/proj",
run_dir: "/run/agent",
model: None,
model_provider: None,
model_provider_base_url: None,
model_provider_env_key: None,
}
}
@ -209,6 +272,52 @@ mod tests {
assert!(CodexPermissionProjector.owned_replace_paths().is_empty());
}
#[test]
fn model_projection_without_permissions_writes_model_provider_and_custom_table() {
let ctx = ProjectionContext {
project_root: "/proj",
run_dir: "/run/agent",
model: Some("gpt-5"),
model_provider: Some("openai-compatible"),
model_provider_base_url: Some("https://models.example.test/v1"),
model_provider_env_key: Some("OPENAI_API_KEY"),
};
let proj = CodexPermissionProjector.project(None, None, &ctx);
assert!(proj.args.is_empty());
match &proj.files[0] {
ProjectedFile::MergeToml {
managed_tables,
managed_keys,
contents,
..
} => {
assert!(managed_keys.contains(&"model".to_owned()));
assert!(managed_keys.contains(&"model_provider".to_owned()));
assert!(
managed_tables.contains(&"model_providers.\"openai-compatible\"".to_owned())
);
assert!(contents.contains("model = \"gpt-5\""), "{contents}");
assert!(
contents.contains("model_provider = \"openai-compatible\""),
"{contents}"
);
assert!(
contents.contains("[model_providers.\"openai-compatible\"]"),
"{contents}"
);
assert!(
contents.contains("base_url = \"https://models.example.test/v1\""),
"{contents}"
);
assert!(
contents.contains("env_key = \"OPENAI_API_KEY\""),
"{contents}"
);
}
ProjectedFile::Replace { .. } => panic!("Codex must emit a MergeToml file"),
}
}
// ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents
// coherence + add-dir + MergeToml shape ---------------------