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