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

@ -1730,6 +1730,67 @@ pub trait WindowStateStore: Send + Sync {
async fn load_window_state(&self) -> Result<crate::layout::WindowStateSnapshot, StoreError>;
}
/// Opaque lookup key for a value held in a [`SecretStore`] (ticket #92, lot B2).
/// Carries no semantics beyond "a stable reference" (e.g. a UUID string minted by
/// the caller) — the store never inspects or derives it. Attached to
/// [`crate::profile::OpenCodeProviderConfig`] and persisted as part of
/// `profiles.json`: safe, since it is only ever an opaque id, never the secret
/// value itself (see [`ProfileStore`]).
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SecretRef(pub String);
impl SecretRef {
/// Wraps an opaque identifier string.
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
/// Returns the inner identifier.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Errors from the [`SecretStore`] port.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SecretStoreError {
/// Underlying I/O error.
#[error("secret store io failed: {0}")]
Io(String),
/// Encryption/decryption failure (corrupt ciphertext, key mismatch, …).
#[error("secret store crypto failure: {0}")]
Crypto(String),
}
/// Port for at-rest storage of secret string values (ticket #92, lot B2, cadrage
/// Architect §B2) — keeps literal API keys OUT of `profiles.json`, which is plain
/// JSON with no encryption. Adapters implementing this port are the ONLY place
/// allowed to hold the encryption key; callers only ever handle plaintext values
/// and opaque [`SecretRef`]s.
#[async_trait]
pub trait SecretStore: Send + Sync {
/// Stores (creates or replaces) the secret value under `key`.
///
/// # Errors
/// [`SecretStoreError`] on I/O or encryption failure.
async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError>;
/// Retrieves the secret value stored under `key`, or `None` if absent.
///
/// # Errors
/// [`SecretStoreError`] on I/O or decryption failure.
async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError>;
/// Deletes the secret stored under `key`. Deleting an absent key is a no-op
/// success (idempotent).
///
/// # Errors
/// [`SecretStoreError`] on I/O failure.
async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError>;
}
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
/// single generic [`AgentRuntime`] adapter (Open/Closed).

View File

@ -354,6 +354,59 @@ impl OpenCodeConfig {
}
}
/// Configuration déclarative d'un profil OpenCode piloté par un provider **cloud**
/// natif du registre OpenCode (ticket #92, lot B1).
///
/// Distincte de [`OpenCodeConfig`] : celle-ci sert le provider custom `llamacpp`
/// (endpoint local compatible OpenAI), celle-ci sert un provider BUILT-IN
/// d'OpenCode (Anthropic, OpenRouter, …) authentifié par une clé API littérale.
/// `AgentProfile::opencode_backend_is_consistent` garantit qu'un profil ne porte
/// jamais les deux configurations à la fois.
///
/// Contrairement à [`OpenCodeConfig::api_key`] (optionnelle, mode local sans
/// authentification), une clé est ici **obligatoire** : un provider cloud sans
/// clé ne peut pas être appelé. Elle n'est cependant jamais portée en littéral —
/// voir [`Self::api_key_ref`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenCodeProviderConfig {
/// Identifiant du provider dans le registre OpenCode (ex. `"anthropic"`,
/// `"openrouter"`). Doit correspondre à une entrée du catalogue (lot B3).
pub provider_id: String,
/// Nom du modèle servi par ce provider (ex. `"claude-sonnet-5"`).
pub model: String,
/// Référence opaque vers la clé API réelle, tenue par un
/// [`crate::ports::SecretStore`] (jamais un littéral en clair — ce struct est
/// persisté tel quel dans `profiles.json`, qui n'est pas chiffré). Seule une
/// couche application autorisée à manipuler le littéral (mint du `SecretRef` +
/// écriture dans le `SecretStore`) construit cette référence.
pub api_key_ref: crate::ports::SecretRef,
}
impl OpenCodeProviderConfig {
/// Construit une configuration validée (parse-don't-validate, comme
/// [`OpenCodeConfig::new`]). Ne prend jamais de clé littérale : seule une
/// référence déjà mintée par l'application est acceptée.
///
/// # Errors
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
pub fn new(
provider_id: impl Into<String>,
model: impl Into<String>,
api_key_ref: crate::ports::SecretRef,
) -> Result<Self, DomainError> {
let provider_id = provider_id.into();
let model = model.into();
crate::validation::non_empty(&provider_id, "opencodeProvider.providerId")?;
crate::validation::non_empty(&model, "opencodeProvider.model")?;
Ok(Self {
provider_id,
model,
api_key_ref,
})
}
}
/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
///
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
@ -774,6 +827,17 @@ pub struct AgentProfile {
/// HTTP OpenAI-compatible in-process.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<OpenCodeConfig>,
/// Configuration OpenCode **cloud** pour [`StructuredAdapter::OpenCode`] (ticket
/// #92, lot B1) : un provider BUILT-IN du registre OpenCode authentifié par clé
/// API, plutôt que le provider custom `llamacpp` de [`Self::opencode`]. Un profil
/// ne porte jamais les deux à la fois — voir
/// [`AgentProfile::opencode_backend_is_consistent`].
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
/// sérialisation : un profil sans provider cloud sérialise exactement comme
/// avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode_provider: Option<OpenCodeProviderConfig>,
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
@ -979,6 +1043,7 @@ impl AgentProfile {
structured_adapter: None,
chat_http: None,
opencode: None,
opencode_provider: None,
mcp: None,
liveness: None,
rate_limit_pattern: None,
@ -1011,6 +1076,14 @@ impl AgentProfile {
self
}
/// Builder : fixe la configuration OpenCode **cloud** (provider BUILT-IN,
/// ticket #92, lot B1).
#[must_use]
pub fn with_opencode_provider(mut self, config: OpenCodeProviderConfig) -> Self {
self.opencode_provider = Some(config);
self
}
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans MCP ne l'appellent simplement pas.
@ -1079,6 +1152,17 @@ impl AgentProfile {
self.structured_adapter.is_some()
}
/// Invariant transverse (ticket #92, lot B1) : un profil OpenCode ne porte
/// **jamais** à la fois [`Self::opencode`] (provider custom `llamacpp`) et
/// [`Self::opencode_provider`] (provider cloud BUILT-IN) — les deux configurent
/// le même champ `opencode.json` `provider`/`model`, et n'ont aucun sens
/// combinées. `true` quand au plus un des deux est `Some` (y compris quand
/// aucun des deux n'est présent — profil non-OpenCode ou OpenCode non configuré).
#[must_use]
pub const fn opencode_backend_is_consistent(&self) -> bool {
!(self.opencode.is_some() && self.opencode_provider.is_some())
}
/// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré
/// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils
/// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation
@ -1258,6 +1342,95 @@ mod mcp_tests {
assert_eq!(back.local_model_server_id, None);
}
// -- Ticket #92, lot B1 : OpenCodeProviderConfig (provider cloud) -----------
#[test]
fn opencode_provider_config_rejects_empty_fields() {
let secret_ref = crate::ports::SecretRef::new("secret-1");
assert!(
OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err()
);
assert!(OpenCodeProviderConfig::new("anthropic", "", secret_ref).is_err());
}
#[test]
fn opencode_provider_config_accepts_valid_fields() {
let secret_ref = crate::ports::SecretRef::new("secret-1");
let config =
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", secret_ref.clone())
.unwrap();
assert_eq!(config.provider_id, "anthropic");
assert_eq!(config.model, "claude-sonnet-5");
assert_eq!(config.api_key_ref, secret_ref);
}
#[test]
fn profile_without_opencode_provider_omits_key_in_json() {
let profile = profile_without_mcp();
assert!(profile.opencode_provider.is_none());
let json = serde_json::to_string(&profile).expect("serialise");
assert!(
!json.contains("\"opencodeProvider\""),
"a profile without an OpenCode cloud provider must NOT serialise \
`opencodeProvider` (zero regression); got: {json}"
);
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
}
#[test]
fn profile_with_opencode_provider_round_trips_camelcase() {
let provider = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-anthropic")).unwrap();
let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode_provider(provider.clone());
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("\"opencodeProvider\""), "got: {json}");
assert!(json.contains("\"providerId\":\"anthropic\""), "got: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.opencode_provider, Some(provider));
}
#[test]
fn opencode_backend_consistency_rejects_both_configs_set() {
let local = OpenCodeConfig::new(
"http://localhost:8080/v1",
None,
"qwen3-coder-30b",
None,
None,
)
.unwrap();
let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap();
let only_local = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(local.clone());
assert!(only_local.opencode_backend_is_consistent());
let only_cloud = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode_provider(cloud.clone());
assert!(only_cloud.opencode_backend_is_consistent());
let neither = profile_without_mcp().with_structured_adapter(StructuredAdapter::OpenCode);
assert!(neither.opencode_backend_is_consistent());
let both = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode(local)
.with_opencode_provider(cloud);
assert!(!both.opencode_backend_is_consistent());
}
#[test]
fn opencode_provider_config_serialises_no_local_model_server_id_leak() {
let config = OpenCodeProviderConfig::new("openrouter", "some-model", crate::ports::SecretRef::new("secret-openrouter")).unwrap();
let json = serde_json::to_string(&config).expect("serialise");
assert!(!json.contains("localModelServerId"));
assert!(!json.contains("baseURL"));
}
#[test]
fn opencode_config_serialises_local_model_server_id_camelcase() {
let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));