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