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

@ -15,8 +15,8 @@ use async_trait::async_trait;
use domain::ids::ProfileId;
use domain::ports::{
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec,
StoreError,
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SecretRef, SecretStore,
SecretStoreError, SessionPlan, SpawnSpec, StoreError,
};
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
use domain::project::ProjectPath;
@ -25,7 +25,8 @@ use application::{
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
};
// ---------------------------------------------------------------------------
@ -78,6 +79,29 @@ impl ProfileStore for FakeProfileStore {
}
}
#[derive(Default, Clone)]
struct FakeSecretStore(Arc<Mutex<HashMap<String, String>>>);
#[async_trait]
impl SecretStore for FakeSecretStore {
async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError> {
self.0
.lock()
.unwrap()
.insert(key.as_str().to_owned(), value.to_owned());
Ok(())
}
async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError> {
Ok(self.0.lock().unwrap().get(key.as_str()).cloned())
}
async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError> {
self.0.lock().unwrap().remove(key.as_str());
Ok(())
}
}
/// Detection outcomes keyed by command. Missing keys ⇒ `false`.
#[derive(Clone)]
enum DetectResult {
@ -392,7 +416,7 @@ async fn save_then_list_then_delete() {
let store = FakeProfileStore::default();
let save = SaveProfile::new(Arc::new(store.clone()));
let list = ListProfiles::new(Arc::new(store.clone()));
let delete = DeleteProfile::new(Arc::new(store.clone()));
let delete = DeleteProfile::new(Arc::new(store.clone()), Arc::new(FakeSecretStore::default()));
let p = profile(1, "Claude", "claude");
let saved = save
@ -413,7 +437,7 @@ async fn save_then_list_then_delete() {
#[tokio::test]
async fn delete_unknown_is_not_found_error() {
let store = FakeProfileStore::default();
let delete = DeleteProfile::new(Arc::new(store));
let delete = DeleteProfile::new(Arc::new(store), Arc::new(FakeSecretStore::default()));
let err = delete
.execute(DeleteProfileInput {
id: ProfileId::from_uuid(uuid::Uuid::from_u128(123)),
@ -423,6 +447,81 @@ async fn delete_unknown_is_not_found_error() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn save_opencode_provider_profile_seals_the_literal_key_behind_a_secret_ref() {
let store = FakeProfileStore::default();
let secrets = FakeSecretStore::default();
let save = SaveOpenCodeProviderProfile::new(
Arc::new(store.clone()),
Arc::new(secrets.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9201)])),
);
let out = save
.execute(SaveOpenCodeProviderProfileInput {
profile: profile(92, "OpenCode Anthropic", "opencode"),
provider_id: "anthropic".to_owned(),
model: "claude-sonnet-5".to_owned(),
api_key: "sk-live-literal-secret".to_owned(),
})
.await
.unwrap();
let provider = out
.profile
.opencode_provider
.as_ref()
.expect("opencode_provider is set");
assert_eq!(provider.provider_id, "anthropic");
assert_eq!(provider.model, "claude-sonnet-5");
// The ref is opaque, not the literal key.
assert_ne!(provider.api_key_ref.as_str(), "sk-live-literal-secret");
// The literal key never leaks onto the returned profile — only the ref does.
let profile_json = serde_json::to_string(&out.profile).unwrap();
assert!(!profile_json.contains("sk-live-literal-secret"));
// The literal key is retrievable ONLY via the SecretStore, through the ref.
let resolved = secrets.get(&provider.api_key_ref).await.unwrap();
assert_eq!(resolved, Some("sk-live-literal-secret".to_owned()));
}
#[tokio::test]
async fn delete_profile_with_opencode_provider_purges_its_secret() {
let store = FakeProfileStore::default();
let secrets = FakeSecretStore::default();
let save = SaveOpenCodeProviderProfile::new(
Arc::new(store.clone()),
Arc::new(secrets.clone()),
Arc::new(SeqIds::new(vec![uuid::Uuid::from_u128(9202)])),
);
let saved = save
.execute(SaveOpenCodeProviderProfileInput {
profile: profile(93, "OpenCode OpenRouter", "opencode"),
provider_id: "openrouter".to_owned(),
model: "anthropic/claude-sonnet-5".to_owned(),
api_key: "sk-live-to-be-purged".to_owned(),
})
.await
.unwrap();
let secret_ref = saved.profile.opencode_provider.as_ref().unwrap().api_key_ref.clone();
assert_eq!(
secrets.get(&secret_ref).await.unwrap(),
Some("sk-live-to-be-purged".to_owned())
);
let delete = DeleteProfile::new(Arc::new(store.clone()), Arc::new(secrets.clone()));
delete
.execute(DeleteProfileInput {
id: saved.profile.id,
})
.await
.unwrap();
assert_eq!(secrets.get(&secret_ref).await.unwrap(), None);
}
#[tokio::test]
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
let store = FakeProfileStore::default();