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

@ -20,7 +20,10 @@ use domain::ports::{
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
SpawnSpec, StoreError, StructuredProviderLaunchPolicy, SystemPermissionStore,
};
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
use domain::profile::{
ClaudeProviderConfig, CodexProviderConfig, McpConfigStrategy, OpenCodeProviderConfig,
StructuredAdapter,
};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
@ -1130,6 +1133,38 @@ fn build_structured_launch_policy(
})
}
fn projection_model(profile: &AgentProfile) -> Option<&str> {
profile
.codex_provider
.as_ref()
.map(|provider| provider.model.as_str())
.or_else(|| {
profile
.claude_provider
.as_ref()
.map(|provider| provider.model.as_str())
})
}
fn projection_model_provider(profile: &AgentProfile) -> Option<&str> {
profile
.codex_provider
.as_ref()
.map(|provider| provider.provider_id.as_str())
}
fn projection_model_provider_base_url(profile: &AgentProfile) -> Option<&str> {
profile
.codex_provider
.as_ref()
.and_then(|provider| provider.custom.as_ref())
.map(|custom| custom.base_url.as_str())
}
fn projection_model_provider_env_key(profile: &AgentProfile) -> Option<&str> {
profile.codex_provider.as_ref().map(|_| "OPENAI_API_KEY")
}
/// Launches an agent: resolve profile + context, prepare the invocation, apply
/// the context-injection plan, open a PTY at the resolved `cwd`, spawn the CLI.
///
@ -1780,6 +1815,8 @@ impl LaunchAgent {
self.ensure_local_model_server_for_opencode(&agent, &mut profile)
.await?;
self.apply_profile_provider_env(&profile, &run_dir, &mut spec)
.await?;
// 5a. ── INJECTION DE LA CONF MCP (cadrage v3, Décision 3) ──
// Strictement APRÈS le convention file (étape 5) et AVANT le spawn /
@ -2208,6 +2245,10 @@ impl LaunchAgent {
let ctx = ProjectionContext {
project_root: project_root.as_str(),
run_dir: run_dir.as_str(),
model: projection_model(profile),
model_provider: projection_model_provider(profile),
model_provider_base_url: projection_model_provider_base_url(profile),
model_provider_env_key: projection_model_provider_env_key(profile),
};
let projection = projector.project(permissions, network, &ctx);
@ -2467,7 +2508,7 @@ impl LaunchAgent {
// `home_env` pointe sur le DOSSIER PARENT de `target` (ex.
// `{runDir}/.codex`), pas sur le fichier — Codex y cherche `config.toml`.
let home_dir = parent_dir(run_dir, target);
spec.env.push((home_env.clone(), home_dir));
upsert_env(&mut spec.env, home_env, &home_dir);
}
domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
@ -2561,6 +2602,64 @@ impl LaunchAgent {
})
}
async fn apply_profile_provider_env(
&self,
profile: &AgentProfile,
run_dir: &ProjectPath,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
if let Some(provider) = profile.codex_provider.as_ref() {
let api_key = self.resolve_codex_provider_api_key(provider).await?;
upsert_env(&mut spec.env, "OPENAI_API_KEY", &api_key);
upsert_env(&mut spec.env, "CODEX_HOME", &join(run_dir, ".codex"));
}
if let Some(provider) = profile.claude_provider.as_ref() {
let api_key = self.resolve_claude_provider_api_key(provider).await?;
upsert_env(&mut spec.env, "ANTHROPIC_API_KEY", &api_key);
}
Ok(())
}
async fn resolve_codex_provider_api_key(
&self,
provider: &CodexProviderConfig,
) -> Result<String, AppError> {
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
AppError::Invalid("Codex provider profile requires a SecretStore, none injected".into())
})?;
secret_store
.get(&provider.api_key_ref)
.await?
.ok_or_else(|| {
AppError::Invalid(format!(
"no secret found for Codex provider `{}` (secret ref `{}`)",
provider.provider_id,
provider.api_key_ref.as_str()
))
})
}
async fn resolve_claude_provider_api_key(
&self,
provider: &ClaudeProviderConfig,
) -> Result<String, AppError> {
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
AppError::Invalid(
"Claude provider profile requires a SecretStore, none injected".into(),
)
})?;
secret_store
.get(&provider.api_key_ref)
.await?
.ok_or_else(|| {
AppError::Invalid(format!(
"no secret found for Claude provider `{}` (secret ref `{}`)",
provider.provider_id,
provider.api_key_ref.as_str()
))
})
}
async fn ensure_local_model_server_for_opencode(
&self,
agent: &Agent,