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

@ -34,11 +34,12 @@ impl PermissionProjector for ClaudePermissionProjector {
_network: Option<domain::NetworkPolicy>,
ctx: &ProjectionContext,
) -> PermissionProjection {
// Product invariant: nothing posed ⇒ nothing projected (native prompting).
let Some(_) = eff else {
// Product invariant: no permissions and no model ⇒ nothing projected
// (native prompting). A model is orthogonal and still gets materialised.
if eff.is_none() && ctx.model.is_none() {
return PermissionProjection::empty();
};
let contents = claude_settings_seed(ctx.project_root, eff);
}
let contents = claude_settings_seed(ctx.project_root, eff, ctx.model);
PermissionProjection {
files: vec![ProjectedFile::Replace {
rel_path: SETTINGS_REL_PATH.to_owned(),
@ -57,7 +58,22 @@ impl PermissionProjector for ClaudePermissionProjector {
/// Builds the Claude Code permission seed. `project_root` is embedded verbatim
/// (JSON-escaped) and granted as an additional working directory, since the cwd is
/// the run dir and the agent works on the root above it.
fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermissions>) -> String {
fn claude_settings_seed(
project_root: &str,
permissions: Option<&EffectivePermissions>,
model: Option<&str>,
) -> String {
let model_line = model
.map(|model| format!(" \"model\": {},\n", json_literal(model)))
.unwrap_or_default();
if permissions.is_none() {
return format!(
r#"{{
{model_line} "enabledMcpjsonServers": ["idea"]
}}
"#
);
}
let root = json_escape(project_root);
let default_mode = match permissions.map(EffectivePermissions::fallback) {
Some(Posture::Deny) => "plan",
@ -80,7 +96,7 @@ fn claude_settings_seed(project_root: &str, permissions: Option<&EffectivePermis
let deny = json_string_array(&merge_default_deny(deny));
format!(
r#"{{
"permissions": {{
{model_line} "permissions": {{
"defaultMode": "{default_mode}",
"additionalDirectories": [
"{root}"
@ -197,6 +213,10 @@ fn json_string_array(items: &[String]) -> String {
format!("[\n{body}\n ]")
}
fn json_literal(value: &str) -> String {
format!("\"{}\"", json_escape(value))
}
#[cfg(test)]
mod tests {
use super::*;
@ -207,6 +227,10 @@ mod tests {
ProjectionContext {
project_root: root,
run_dir,
model: None,
model_provider: None,
model_provider_base_url: None,
model_provider_env_key: None,
}
}
@ -255,6 +279,31 @@ mod tests {
assert!(proj.env.is_empty());
}
#[test]
fn model_projects_even_without_permissions() {
let ctx = ProjectionContext {
project_root: "/proj",
run_dir: "/run",
model: Some("claude-sonnet-4-5"),
model_provider: None,
model_provider_base_url: None,
model_provider_env_key: None,
};
let proj = ClaudePermissionProjector.project(None, None, &ctx);
assert!(proj.args.is_empty());
assert!(proj.env.is_empty());
assert_eq!(proj.files.len(), 1);
match &proj.files[0] {
ProjectedFile::Replace { rel_path, contents } => {
assert_eq!(rel_path, SETTINGS_REL_PATH);
let json: Value = serde_json::from_str(contents).unwrap();
assert_eq!(json["model"], "claude-sonnet-4-5");
assert!(json.get("permissions").is_none());
}
ProjectedFile::MergeToml { .. } => panic!("Claude must emit a Replace file"),
}
}
#[test]
fn owned_replace_paths_is_the_settings_file() {
assert_eq!(