feat(backend): support des providers OpenCode cloud (#92)

Ajoute le catalogue statique de providers OpenCode (lot B3), le stockage
sécurisé des secrets (SecretStore + adapter infrastructure), et les
use cases SaveOpenCodeProviderProfile/DeleteProfile câblés en composition
root. Couvre le fix B1 et les tests de régression demandés par QA.

cargo build --workspace propre, cargo test --workspace -- --test-threads=1
intégralement vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 08:03:03 +02:00
parent bece7c92c5
commit 23a3c2788f
20 changed files with 1311 additions and 55 deletions

View File

@ -17,10 +17,10 @@ use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
StoreError,
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
SpawnSpec, StoreError,
};
use domain::profile::{McpConfigStrategy, StructuredAdapter};
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
use domain::{
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
@ -1150,6 +1150,12 @@ pub struct LaunchAgent {
/// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
/// when `OpenCodeConfig.localModelServerId` is set.
local_model_server: Option<Arc<EnsureLocalModelServer>>,
/// Resolves the literal API key of an [`domain::profile::OpenCodeProviderConfig`]
/// (ticket #92, lot B3) just before writing `opencode.json`. Optional for legacy
/// tests/wiring; required at runtime when `profile.opencode_provider` is set — a
/// missing store or a missing/undecryptable secret **fails the launch**
/// (never spawns with a dead/absent key).
secret_store: Option<Arc<dyn SecretStore>>,
/// Explicit intent for structured profiles when structured ports are absent.
structured_routing_mode: StructuredRoutingMode,
}
@ -1191,6 +1197,7 @@ impl LaunchAgent {
projectors: None,
live_state_lean: None,
local_model_server: None,
secret_store: None,
structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
}
}
@ -1209,6 +1216,17 @@ impl LaunchAgent {
self
}
/// Injects the [`SecretStore`] used to resolve an
/// [`domain::profile::OpenCodeProviderConfig`]'s literal API key at launch
/// (ticket #92, lot B3). Without this call (legacy call sites / tests), a
/// profile carrying `opencode_provider` fails its launch instead of silently
/// writing an unauthenticated `opencode.json`.
#[must_use]
pub fn with_secret_store(mut self, secret_store: Arc<dyn SecretStore>) -> Self {
self.secret_store = Some(secret_store);
self
}
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
/// `# État du projet` (status + intent des autres agents) est injecté dans le
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
@ -1697,7 +1715,7 @@ impl LaunchAgent {
input.mcp_runtime.as_ref(),
&mut spec,
)
.await;
.await?;
// 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
// Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
@ -2254,9 +2272,9 @@ impl LaunchAgent {
project_root: &ProjectPath,
runtime: Option<&McpRuntime>,
spec: &mut SpawnSpec,
) {
) -> Result<(), AppError> {
let Some(mcp) = &profile.mcp else {
return;
return Ok(());
};
match &mcp.config {
domain::profile::McpConfigStrategy::ConfigFile { target } => {
@ -2342,10 +2360,21 @@ impl LaunchAgent {
}
domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
return;
return Ok(());
}
let Some(opencode) = profile.opencode.as_ref() else {
return;
let body = if let Some(opencode) = profile.opencode.as_ref() {
opencode_config_json(opencode, project_root.as_str(), runtime).to_string()
} else if let Some(provider) = profile.opencode_provider.as_ref() {
let api_key = self.resolve_opencode_provider_api_key(provider).await?;
opencode_provider_config_json(
provider,
&api_key,
project_root.as_str(),
runtime,
)
.to_string()
} else {
return Ok(());
};
let config_path = join(run_dir, target);
let opencode_home = join(run_dir, ".opencode");
@ -2361,8 +2390,6 @@ impl LaunchAgent {
.create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
.await;
}
let body =
opencode_config_json(opencode, project_root.as_str(), runtime).to_string();
let _ = self
.fs
.write(&RemotePath::new(config_path.clone()), body.as_bytes())
@ -2387,6 +2414,39 @@ impl LaunchAgent {
spec.env.push((var.clone(), run_dir.as_str().to_owned()));
}
}
Ok(())
}
/// Resolves the literal API key of an [`OpenCodeProviderConfig`] through the
/// injected [`SecretStore`] (ticket #92, lot B3). Unlike the rest of
/// [`Self::apply_mcp_config`] (best-effort, never fails a launch), this fails
/// **hard**: an OpenCode cloud profile with no resolvable key must never spawn
/// with a dead/absent key, silently falling back to the CLI's native provider
/// prompting.
///
/// # Errors
/// [`AppError::Invalid`] if no [`SecretStore`] was injected
/// ([`Self::with_secret_store`]) or the secret is absent (corrupted/deleted
/// out from under the profile); [`AppError::Store`] on store failure.
async fn resolve_opencode_provider_api_key(
&self,
provider: &OpenCodeProviderConfig,
) -> Result<String, AppError> {
let secret_store = self.secret_store.as_ref().ok_or_else(|| {
AppError::Invalid(
"OpenCode cloud 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 OpenCode provider `{}` (secret ref `{}`)",
provider.provider_id,
provider.api_key_ref.as_str()
))
})
}
async fn ensure_local_model_server_for_opencode(
@ -2679,6 +2739,66 @@ fn opencode_config_json(
serde_json::Value::Object(root)
}
/// Renders the OpenCode config for a **cloud** provider profile (ticket #92, lot
/// B3), mirroring [`opencode_config_json`]. Unlike the `llamacpp` custom-provider
/// shape, `provider.provider_id` is a BUILT-IN OpenCode provider (`anthropic`,
/// `openrouter`, …): it needs only the resolved `apiKey` override, no
/// `npm`/`name`/`models` block, and it must NOT appear in `disabled_providers`
/// (that list exists to keep the local llamacpp profile from picking up a
/// built-in provider by accident — the opposite of what a cloud profile wants).
fn opencode_provider_config_json(
config: &OpenCodeProviderConfig,
api_key: &str,
project_root: &str,
runtime: Option<&McpRuntime>,
) -> serde_json::Value {
let mut root = serde_json::Map::new();
root.insert(
"$schema".to_owned(),
serde_json::Value::String("https://opencode.ai/config.json".to_owned()),
);
root.insert(
"model".to_owned(),
serde_json::Value::String(format!("{}/{}", config.provider_id, config.model)),
);
root.insert(
"provider".to_owned(),
serde_json::json!({
config.provider_id.as_str(): {
"options": {
"apiKey": api_key
}
}
}),
);
let wiring = mcp_server_wiring(domain::profile::McpTransport::Stdio, runtime);
let command_array = std::iter::once(wiring.command)
.chain(wiring.args)
.map(serde_json::Value::String)
.collect::<Vec<_>>();
root.insert(
"mcp".to_owned(),
serde_json::json!({
"idea": {
"type": "local",
"command": command_array,
"cwd": project_root,
"enabled": true,
"timeout": 15000
}
}),
);
root.insert(
"permission".to_owned(),
serde_json::json!({
"bash": "ask",
"edit": "ask"
}),
);
serde_json::Value::Object(root)
}
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
/// segment using a POSIX separator.
fn join(base: &ProjectPath, rel: &str) -> String {