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

@ -23,6 +23,10 @@ serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
# AEAD encryption for the at-rest `SecretStore` adapter (ticket #92, lot B2).
# Already vendored transitively (rustls/reqwest use it) — made an explicit direct
# dependency here rather than adding a new crate to the tree.
ring = "0.17"
# Moteur regex du détecteur de limite de session niveau 2 (ARCHITECTURE §21.2-T2) :
# le DOMAINE ne porte que la donnée du motif (`RateLimitPattern`) ; le moteur regex
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur

View File

@ -4,8 +4,8 @@ use std::sync::Arc;
use application::McpRuntime;
use async_trait::async_trait;
use domain::ports::SessionPlan;
use domain::profile::{McpConfigStrategy, StructuredAdapter};
use domain::ports::{SecretStore, SessionPlan};
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
use domain::{
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring,
@ -92,25 +92,57 @@ pub struct TicketAssistantEnvironmentPreparer {
app_data_dir: String,
runtime: Arc<dyn AgentRuntime>,
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
/// Resolves the literal API key of an [`OpenCodeProviderConfig`] (ticket #92,
/// lot B3) just before writing `opencode.json`. A missing/undecryptable
/// secret fails the ticket-assistant launch — see
/// [`Self::resolve_opencode_provider_api_key`].
secret_store: Arc<dyn SecretStore>,
}
impl TicketAssistantEnvironmentPreparer {
/// Builds the preparer from filesystem, app-data dir, runtime and MCP resolver.
/// Builds the preparer from filesystem, app-data dir, runtime, MCP resolver
/// and secret store.
#[must_use]
pub fn new(
fs: Arc<dyn FileSystem>,
app_data_dir: impl Into<String>,
runtime: Arc<dyn AgentRuntime>,
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
secret_store: Arc<dyn SecretStore>,
) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
runtime,
mcp_runtime,
secret_store,
}
}
/// Resolves the literal API key of an [`OpenCodeProviderConfig`] through the
/// injected [`SecretStore`] (ticket #92, lot B3). Fails hard — never spawns
/// the ticket assistant with a dead/absent key.
///
/// # Errors
/// [`RuntimeError::Invocation`] if the secret is absent (corrupted/deleted out
/// from under the profile) or the store fails.
async fn resolve_opencode_provider_api_key(
&self,
provider: &OpenCodeProviderConfig,
) -> Result<String, RuntimeError> {
self.secret_store
.get(&provider.api_key_ref)
.await
.map_err(|e| RuntimeError::Invocation(e.to_string()))?
.ok_or_else(|| {
RuntimeError::Invocation(format!(
"no secret found for OpenCode provider `{}` (secret ref `{}`)",
provider.provider_id,
provider.api_key_ref.as_str()
))
})
}
fn run_dir(&self, project: &Project, issue_ref: IssueRef) -> Result<ProjectPath, RuntimeError> {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
let project_id = project.id.as_uuid().simple();
@ -211,7 +243,19 @@ impl TicketAssistantEnvironmentPreparer {
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
return Ok(());
}
let Some(opencode) = profile.opencode.as_ref() else {
let body = if let Some(opencode) = profile.opencode.as_ref() {
opencode_config_json(opencode, project.root.as_str(), runtime.as_ref())
.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.as_ref(),
)
.to_string()
} else {
return Ok(());
};
let config_path = join(cwd, target);
@ -222,8 +266,6 @@ impl TicketAssistantEnvironmentPreparer {
for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
self.create_dir(dir).await?;
}
let body = opencode_config_json(opencode, project.root.as_str(), runtime.as_ref())
.to_string();
self.write_file(&config_path, body.as_bytes()).await?;
env.extend([
("OPENCODE_CONFIG".to_owned(), config_path),
@ -375,6 +417,77 @@ fn opencode_config_json(
Value::Object(root)
}
/// Renders the OpenCode config for a **cloud** provider profile (ticket #92, lot
/// B3), mirroring [`opencode_config_json`]. See the sibling implementation in
/// `application::agent::lifecycle` for the rationale (duplication flagged as
/// separate debt, not tripled here).
fn opencode_provider_config_json(
config: &OpenCodeProviderConfig,
api_key: &str,
project_root: &str,
runtime: Option<&McpRuntime>,
) -> Value {
let mut root = Map::new();
root.insert(
"$schema".to_owned(),
Value::String("https://opencode.ai/config.json".to_owned()),
);
root.insert(
"model".to_owned(),
Value::String(format!("{}/{}", config.provider_id, config.model)),
);
root.insert(
"provider".to_owned(),
json!({
config.provider_id.as_str(): {
"options": {
"apiKey": api_key
}
}
}),
);
let (command, args) = match runtime {
Some(rt) => (
rt.exe.clone(),
vec![
"mcp-server".to_owned(),
"--endpoint".to_owned(),
rt.endpoint.clone(),
"--project".to_owned(),
rt.project_id.clone(),
"--requester".to_owned(),
rt.requester.clone(),
],
),
None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
};
let command_array = std::iter::once(command)
.chain(args)
.map(Value::String)
.collect::<Vec<_>>();
root.insert(
"mcp".to_owned(),
json!({
"idea": {
"type": "local",
"command": command_array,
"cwd": project_root,
"enabled": true,
"timeout": 15000
}
}),
);
root.insert(
"permission".to_owned(),
json!({
"bash": "ask",
"edit": "ask"
}),
);
Value::Object(root)
}
fn codex_config_toml(
existing: Option<&str>,
mcp_declaration: &str,

View File

@ -102,7 +102,8 @@ pub use store::{
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore,
NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsSecretStore, FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder,
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
VECTOR_ONNX_ENABLED,
};

View File

@ -14,6 +14,7 @@ mod memory;
mod permission;
mod profile;
mod project;
mod secrets;
mod skill;
mod template;
mod vector;
@ -37,6 +38,7 @@ pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
pub use permission::FsPermissionStore;
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
pub use project::FsProjectStore;
pub use secrets::FsSecretStore;
pub use skill::FsSkillStore;
pub use template::FsTemplateStore;
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};

View File

@ -0,0 +1,320 @@
//! [`FsSecretStore`] — encrypted-at-rest [`SecretStore`] adapter (ticket #92, lot B2).
//!
//! ```text
//! <app_data_dir>/
//! ├── secret.key # 32 raw bytes, AES-256-GCM key material, chmod 0600 (Unix)
//! └── secrets.json # { version, entries: { <secretRefId>: "<hex nonce+ciphertext>" } }
//! ```
//!
//! Unlike `profiles.json` (plain JSON), `secrets.json` never carries a plaintext
//! value: each entry is `nonce (12B) || AES-256-GCM(value)`, hex-encoded. The key
//! is generated once (random, `ring::rand::SystemRandom`) and persisted next to
//! it. No OS keyring integration in v1 (assumed debt, documented by Architect) —
//! a local attacker with read access to `secret.key` recovers every secret; the
//! win over `profiles.json` is that `secrets.json` alone (e.g. leaked in a backup
//! that excludes `secret.key`, or read by a process without access to the key
//! file's restrictive permissions) is inert ciphertext.
use std::sync::Arc;
use async_trait::async_trait;
use ring::aead::{self, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, UnboundKey};
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use domain::ports::{FileSystem, FsError, RemotePath, SecretRef, SecretStore, SecretStoreError};
const SECRETS_FILE: &str = "secrets.json";
const KEY_FILE: &str = "secret.key";
const SECRETS_VERSION: u32 = 1;
const KEY_LEN: usize = 32; // AES-256
const NONCE_LEN: usize = 12;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SecretsDoc {
version: u32,
/// `SecretRef` id -> hex(nonce || ciphertext || tag).
entries: std::collections::BTreeMap<String, String>,
}
impl Default for SecretsDoc {
fn default() -> Self {
Self {
version: SECRETS_VERSION,
entries: std::collections::BTreeMap::new(),
}
}
}
/// Single-use nonce sequence wrapping one 12-byte value (ring's streaming AEAD
/// API insists on a `NonceSequence`, but every seal/open here uses exactly one
/// fresh/parsed nonce, never a stream).
struct OnceNonce(Option<[u8; NONCE_LEN]>);
impl NonceSequence for OnceNonce {
fn advance(&mut self) -> Result<Nonce, ring::error::Unspecified> {
let bytes = self.0.take().ok_or(ring::error::Unspecified)?;
Ok(Nonce::assume_unique_for_key(bytes))
}
}
/// Encrypted-at-rest [`SecretStore`] port implementation.
///
/// Cheap to clone (everything behind `Arc`); built once at the composition root.
#[derive(Clone)]
pub struct FsSecretStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsSecretStore {
/// Builds the store from an injected [`FileSystem`] and the app-data dir
/// (same machine-local directory as `profiles.json`).
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
fn path(&self, file: &str) -> RemotePath {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{file}"))
}
/// Loads the key material, generating and persisting a fresh random key on
/// first use. The key file is written with `0600` permissions on Unix
/// (best-effort no-op elsewhere).
async fn load_or_init_key(&self) -> Result<[u8; KEY_LEN], SecretStoreError> {
let key_path = self.path(KEY_FILE);
match self.fs.read(&key_path).await {
Ok(bytes) => {
let key: [u8; KEY_LEN] = bytes
.as_slice()
.try_into()
.map_err(|_| SecretStoreError::Crypto("secret.key has wrong length".into()))?;
Ok(key)
}
Err(FsError::NotFound(_)) => {
let mut key = [0_u8; KEY_LEN];
SystemRandom::new()
.fill(&mut key)
.map_err(|_| SecretStoreError::Crypto("failed to generate secret key".into()))?;
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
self.fs
.write(&key_path, &key)
.await
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
restrict_permissions(key_path.as_str());
Ok(key)
}
Err(e) => Err(SecretStoreError::Io(e.to_string())),
}
}
async fn read_doc(&self) -> Result<SecretsDoc, SecretStoreError> {
match self.fs.read(&self.path(SECRETS_FILE)).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| SecretStoreError::Io(format!("secrets.json parse error: {e}"))),
Err(FsError::NotFound(_)) => Ok(SecretsDoc::default()),
Err(e) => Err(SecretStoreError::Io(e.to_string())),
}
}
async fn write_doc(&self, doc: &SecretsDoc) -> Result<(), SecretStoreError> {
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs
.create_dir_all(&dir)
.await
.map_err(|e| SecretStoreError::Io(e.to_string()))?;
let bytes = serde_json::to_vec_pretty(doc)
.map_err(|e| SecretStoreError::Io(format!("secrets.json serialise error: {e}")))?;
self.fs
.write(&self.path(SECRETS_FILE), &bytes)
.await
.map_err(|e| SecretStoreError::Io(e.to_string()))
}
fn seal(key: &[u8; KEY_LEN], plaintext: &str) -> Result<String, SecretStoreError> {
let mut nonce_bytes = [0_u8; NONCE_LEN];
SystemRandom::new()
.fill(&mut nonce_bytes)
.map_err(|_| SecretStoreError::Crypto("failed to generate nonce".into()))?;
let unbound = UnboundKey::new(&aead::AES_256_GCM, key)
.map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?;
let mut sealing = SealingKey::new(unbound, OnceNonce(Some(nonce_bytes)));
let mut in_out = plaintext.as_bytes().to_vec();
sealing
.seal_in_place_append_tag(aead::Aad::empty(), &mut in_out)
.map_err(|_| SecretStoreError::Crypto("seal failed".into()))?;
let mut out = Vec::with_capacity(NONCE_LEN + in_out.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&in_out);
Ok(hex::encode(out))
}
fn open(key: &[u8; KEY_LEN], encoded: &str) -> Result<String, SecretStoreError> {
let bytes =
hex::decode(encoded).map_err(|e| SecretStoreError::Crypto(format!("bad hex: {e}")))?;
if bytes.len() < NONCE_LEN {
return Err(SecretStoreError::Crypto("ciphertext too short".into()));
}
let (nonce_bytes, ciphertext) = bytes.split_at(NONCE_LEN);
let mut nonce_arr = [0_u8; NONCE_LEN];
nonce_arr.copy_from_slice(nonce_bytes);
let unbound = UnboundKey::new(&aead::AES_256_GCM, key)
.map_err(|_| SecretStoreError::Crypto("invalid key material".into()))?;
let mut opening = OpeningKey::new(unbound, OnceNonce(Some(nonce_arr)));
let mut in_out = ciphertext.to_vec();
let plaintext = opening
.open_in_place(aead::Aad::empty(), &mut in_out)
.map_err(|_| SecretStoreError::Crypto("decryption failed".into()))?;
String::from_utf8(plaintext.to_vec())
.map_err(|e| SecretStoreError::Crypto(format!("decrypted value is not utf8: {e}")))
}
}
#[async_trait]
impl SecretStore for FsSecretStore {
async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError> {
let enc_key = self.load_or_init_key().await?;
let mut doc = self.read_doc().await?;
let sealed = Self::seal(&enc_key, value)?;
doc.entries.insert(key.as_str().to_owned(), sealed);
self.write_doc(&doc).await
}
async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError> {
let doc = self.read_doc().await?;
let Some(sealed) = doc.entries.get(key.as_str()) else {
return Ok(None);
};
let enc_key = self.load_or_init_key().await?;
Self::open(&enc_key, sealed).map(Some)
}
async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError> {
let mut doc = self.read_doc().await?;
doc.entries.remove(key.as_str());
self.write_doc(&doc).await
}
}
#[cfg(unix)]
fn restrict_permissions(path: &str) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
let _ = std::fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &str) {}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let root = std::env::temp_dir().join(format!("idea-secrets-store-{label}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
Self(root)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn store(dir: &TempDir) -> FsSecretStore {
FsSecretStore::new(
Arc::new(crate::fs::LocalFileSystem::new()),
dir.0.to_string_lossy().into_owned(),
)
}
#[tokio::test]
async fn put_then_get_round_trips_plaintext() {
let dir = TempDir::new("roundtrip");
let store = store(&dir);
let key = SecretRef::new("secret-a");
store.put(&key, "sk-live-abc123").await.unwrap();
let got = store.get(&key).await.unwrap();
assert_eq!(got, Some("sk-live-abc123".to_owned()));
}
#[tokio::test]
async fn get_missing_key_returns_none() {
let dir = TempDir::new("missing");
let store = store(&dir);
let got = store.get(&SecretRef::new("nope")).await.unwrap();
assert_eq!(got, None);
}
#[tokio::test]
async fn delete_removes_the_entry() {
let dir = TempDir::new("delete");
let store = store(&dir);
let key = SecretRef::new("secret-b");
store.put(&key, "value").await.unwrap();
store.delete(&key).await.unwrap();
assert_eq!(store.get(&key).await.unwrap(), None);
}
#[tokio::test]
async fn secrets_file_never_contains_the_plaintext_value() {
let dir = TempDir::new("plaintext-leak");
let store = store(&dir);
store
.put(&SecretRef::new("secret-c"), "super-secret-literal")
.await
.unwrap();
let raw = tokio::fs::read_to_string(dir.0.join(SECRETS_FILE))
.await
.unwrap();
assert!(!raw.contains("super-secret-literal"));
}
#[tokio::test]
async fn key_file_has_owner_only_permissions_on_unix() {
let dir = TempDir::new("perms");
let store = store(&dir);
store.put(&SecretRef::new("secret-d"), "value").await.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(dir.0.join(KEY_FILE)).unwrap();
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
}
}
#[tokio::test]
async fn put_reuses_the_same_key_across_calls() {
let dir = TempDir::new("reuse-key");
let store = store(&dir);
store.put(&SecretRef::new("a"), "value-a").await.unwrap();
store.put(&SecretRef::new("b"), "value-b").await.unwrap();
assert_eq!(
store.get(&SecretRef::new("a")).await.unwrap(),
Some("value-a".to_owned())
);
assert_eq!(
store.get(&SecretRef::new("b")).await.unwrap(),
Some("value-b".to_owned())
);
}
}

View File

@ -12,7 +12,7 @@ use domain::{
SpawnSpec,
};
use infrastructure::{
FsAssistantContextStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
FsAssistantContextStore, FsSecretStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
};
use uuid::Uuid;
@ -195,6 +195,7 @@ async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_da
requester: requester.clone(),
})
}),
Arc::new(FsSecretStore::new(fs.clone(), app_data_dir.clone())),
);
let env = preparer