From 23a3c2788f95691fc44f4bf9a25c1a88ed6bd40e Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 23 Jul 2026 08:03:03 +0200 Subject: [PATCH] feat(backend): support des providers OpenCode cloud (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 1 + crates/app-tauri/src/commands.rs | 37 +- crates/app-tauri/src/lib.rs | 2 + crates/application/src/agent/lifecycle.rs | 142 +++++++- crates/application/src/agent/mod.rs | 8 +- .../src/agent/provider_catalogue.rs | 110 ++++++ crates/application/src/agent/usecases.rs | 116 ++++++- crates/application/src/error.rs | 10 +- crates/application/src/lib.rs | 18 +- crates/application/tests/profile_usecases.rs | 109 +++++- crates/backend/src/dto.rs | 70 +++- crates/backend/src/lib.rs | 48 ++- crates/domain/src/ports.rs | 61 ++++ crates/domain/src/profile.rs | 173 ++++++++++ crates/infrastructure/Cargo.toml | 4 + crates/infrastructure/src/assistant/mod.rs | 125 ++++++- crates/infrastructure/src/lib.rs | 7 +- crates/infrastructure/src/store/mod.rs | 2 + crates/infrastructure/src/store/secrets.rs | 320 ++++++++++++++++++ .../tests/assistant_context_store.rs | 3 +- 20 files changed, 1311 insertions(+), 55 deletions(-) create mode 100644 crates/application/src/agent/provider_catalogue.rs create mode 100644 crates/infrastructure/src/store/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index 92852ce..c6b5c5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1977,6 +1977,7 @@ dependencies = [ "portable-pty", "regex", "reqwest 0.12.28", + "ring", "serde", "serde_json", "sha2", diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d8581c8..c87d9e0 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -50,13 +50,15 @@ use crate::dto::{ HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, - ModelServerConfigListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, - ProfileListDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, + PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, - SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, + SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, + SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, @@ -1092,6 +1094,35 @@ pub async fn save_profile( .map_err(ErrorDto::from) } +/// `list_opencode_providers` — static catalogue of OpenCode cloud providers +/// (ticket #92, lot B3). +#[tauri::command] +pub async fn list_opencode_providers( + state: State<'_, AppState>, +) -> Result { + Ok(state.list_opencode_providers.execute().into()) +} + +/// `save_opencode_provider_profile` — create or replace an OpenCode profile +/// backed by a cloud provider (ticket #92, lot B3). The literal API key is +/// sealed into the `SecretStore`, never persisted in `profiles.json`. +/// +/// # Errors +/// Returns an [`ErrorDto`] (`INVALID` for an empty `providerId`/`model`, +/// `STORE` on secret or profile persistence failure). +#[tauri::command] +pub async fn save_opencode_provider_profile( + request: SaveOpenCodeProviderProfileRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .save_opencode_provider_profile + .execute(request.into()) + .await + .map(ProfileDto::from) + .map_err(ErrorDto::from) +} + /// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance /// from the canonical `opencode-llamacpp` seed/template. /// diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 60f844a..216cf9f 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -249,6 +249,8 @@ pub fn run() { commands::detect_profiles, commands::list_profiles, commands::save_profile, + commands::save_opencode_provider_profile, + commands::list_opencode_providers, commands::clone_opencode_profile_from_seed, commands::delete_profile, commands::configure_profiles, diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index f6b2f12..e4ce9e4 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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>, + /// 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>, /// 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) -> 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 { + 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::>(); + 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 { diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 8c7a5aa..02f7bf5 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -9,6 +9,7 @@ mod catalogue; mod inspect; mod lifecycle; +mod provider_catalogue; mod resume; mod session_limit; mod structured; @@ -28,6 +29,10 @@ pub use catalogue::{ reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS, }; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; +pub use provider_catalogue::{ + opencode_provider_catalogue, ListOpenCodeProviders, ListOpenCodeProvidersOutput, + OpenCodeProviderCatalogEntry, +}; pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider, @@ -46,5 +51,6 @@ pub use usecases::{ ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles, ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput, - SaveProfile, SaveProfileInput, SaveProfileOutput, + SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, + SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, }; diff --git a/crates/application/src/agent/provider_catalogue.rs b/crates/application/src/agent/provider_catalogue.rs new file mode 100644 index 0000000..87650dd --- /dev/null +++ b/crates/application/src/agent/provider_catalogue.rs @@ -0,0 +1,110 @@ +//! Static catalogue of OpenCode **cloud** providers (ticket #92, lot B3). +//! +//! No OpenCode sub-command exposes a stable, machine-readable provider list +//! (cadrage Architect), so the catalogue is hard-coded data — same pattern as +//! [`super::catalogue::reference_profiles`]: a product decision about *which* +//! providers to offer, expressed as data, not code (Open/Closed). + +/// One entry of the OpenCode cloud-provider catalogue. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpenCodeProviderCatalogEntry { + /// Identifier in the OpenCode provider registry (e.g. `"anthropic"`). + pub provider_id: &'static str, + /// Human-readable label for the picker UI. + pub display_name: &'static str, + /// Model names this provider serves, offered for selection. + pub models: &'static [&'static str], +} + +/// Returns the static OpenCode cloud-provider catalogue. +#[must_use] +pub fn opencode_provider_catalogue() -> &'static [OpenCodeProviderCatalogEntry] { + &[ + OpenCodeProviderCatalogEntry { + provider_id: "anthropic", + display_name: "Anthropic", + models: &[ + "claude-sonnet-5", + "claude-opus-4-8", + "claude-haiku-4-5-20251001", + ], + }, + OpenCodeProviderCatalogEntry { + provider_id: "openrouter", + display_name: "OpenRouter", + models: &[ + "anthropic/claude-sonnet-5", + "openai/gpt-5", + "google/gemini-3-pro", + ], + }, + OpenCodeProviderCatalogEntry { + provider_id: "openai", + display_name: "OpenAI", + models: &["gpt-5", "gpt-5-mini"], + }, + ] +} + +/// Use case exposing [`opencode_provider_catalogue`] to the driving side. No +/// port: the catalogue is pure static data, not something to fetch through I/O. +pub struct ListOpenCodeProviders; + +/// Output of [`ListOpenCodeProviders::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListOpenCodeProvidersOutput { + /// The static catalogue entries. + pub providers: Vec, +} + +impl ListOpenCodeProviders { + /// Builds the use case (stateless, no ports to inject). + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Lists the static OpenCode cloud-provider catalogue. + #[must_use] + pub fn execute(&self) -> ListOpenCodeProvidersOutput { + ListOpenCodeProvidersOutput { + providers: opencode_provider_catalogue().to_vec(), + } + } +} + +impl Default for ListOpenCodeProviders { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalogue_entries_have_non_empty_ids_and_models() { + for entry in opencode_provider_catalogue() { + assert!(!entry.provider_id.is_empty()); + assert!(!entry.display_name.is_empty()); + assert!(!entry.models.is_empty()); + } + } + + #[test] + fn provider_ids_are_unique() { + let catalogue = opencode_provider_catalogue(); + for (i, a) in catalogue.iter().enumerate() { + for b in &catalogue[i + 1..] { + assert_ne!(a.provider_id, b.provider_id); + } + } + } + + #[test] + fn list_opencode_providers_returns_the_static_catalogue() { + let output = ListOpenCodeProviders::new().execute(); + assert_eq!(output.providers.len(), opencode_provider_catalogue().len()); + } +} diff --git a/crates/application/src/agent/usecases.rs b/crates/application/src/agent/usecases.rs index f5ba5ac..f6dcb34 100644 --- a/crates/application/src/agent/usecases.rs +++ b/crates/application/src/agent/usecases.rs @@ -14,8 +14,8 @@ use std::sync::Arc; use domain::ids::ProfileId; -use domain::ports::{AgentRuntime, IdGenerator, ProfileStore}; -use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter}; +use domain::ports::{AgentRuntime, IdGenerator, ProfileStore, SecretRef, SecretStore}; +use domain::profile::{AgentProfile, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter}; use crate::error::AppError; @@ -272,6 +272,95 @@ impl SaveProfile { } } +// --------------------------------------------------------------------------- +// SaveOpenCodeProviderProfile +// --------------------------------------------------------------------------- + +/// Input for [`SaveOpenCodeProviderProfile::execute`]: the profile to upsert +/// (with [`AgentProfile::opencode_provider`] left as-is — this use case fills it +/// in) plus the **literal** provider fields. The literal `api_key` never reaches +/// [`ProfileStore`] — only [`SaveOpenCodeProviderProfile`] is allowed to touch it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveOpenCodeProviderProfileInput { + /// The profile to create or replace (by id). Its `opencode_provider` field is + /// overwritten by this use case; any value set on it is ignored. + pub profile: AgentProfile, + /// Provider id in the OpenCode registry (e.g. `"anthropic"`). + pub provider_id: String, + /// Model name served by this provider. + pub model: String, + /// Literal API key. Minted into a fresh [`SecretRef`] on first save, or + /// re-sealed under the profile's existing `SecretRef` on edit — never + /// persisted as a literal in `profiles.json`. + pub api_key: String, +} + +/// Output of [`SaveOpenCodeProviderProfile::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SaveOpenCodeProviderProfileOutput { + /// The saved profile (echoed back), with `opencode_provider` set. + pub profile: AgentProfile, +} + +/// Persists an OpenCode profile backed by a **cloud** provider (ticket #92, lot +/// B3), keeping the literal API key out of `profiles.json`: it is sealed into the +/// [`SecretStore`] under an opaque [`SecretRef`], and only the ref is persisted on +/// [`domain::profile::OpenCodeProviderConfig`]. +pub struct SaveOpenCodeProviderProfile { + profile_store: Arc, + secret_store: Arc, + ids: Arc, +} + +impl SaveOpenCodeProviderProfile { + /// Builds the use case from the profile store, secret store and id generator + /// ports. + #[must_use] + pub fn new( + profile_store: Arc, + secret_store: Arc, + ids: Arc, + ) -> Self { + Self { + profile_store, + secret_store, + ids, + } + } + + /// Seals `input.api_key` under a [`SecretRef`] (minted fresh, or reused from + /// the profile's existing `opencode_provider` when editing) and persists the + /// profile with `opencode_provider` set to the resulting + /// [`domain::profile::OpenCodeProviderConfig`]. + /// + /// # Errors + /// [`AppError::Invalid`] if `provider_id`/`model` is empty, [`AppError::Store`] + /// on secret or profile persistence failure. + pub async fn execute( + &self, + input: SaveOpenCodeProviderProfileInput, + ) -> Result { + let secret_ref = input + .profile + .opencode_provider + .as_ref() + .map(|config| config.api_key_ref.clone()) + .unwrap_or_else(|| SecretRef::new(self.ids.new_uuid().to_string())); + + self.secret_store.put(&secret_ref, &input.api_key).await?; + + let provider = + OpenCodeProviderConfig::new(input.provider_id, input.model, secret_ref) + .map_err(|e| AppError::Invalid(e.to_string()))?; + + let mut profile = input.profile; + profile.opencode_provider = Some(provider); + + self.profile_store.save(&profile).await?; + Ok(SaveOpenCodeProviderProfileOutput { profile }) + } +} + // --------------------------------------------------------------------------- // DeleteProfile // --------------------------------------------------------------------------- @@ -283,24 +372,37 @@ pub struct DeleteProfileInput { pub id: domain::ids::ProfileId, } -/// Deletes a profile by id. +/// Deletes a profile by id. If the profile carries an +/// [`domain::profile::OpenCodeProviderConfig`], its secret is removed from the +/// [`SecretStore`] first, so no orphaned secret is left behind (ticket #92, lot +/// B3). pub struct DeleteProfile { store: Arc, + secret_store: Arc, } impl DeleteProfile { - /// Builds the use case from the [`ProfileStore`] port. + /// Builds the use case from the [`ProfileStore`] and [`SecretStore`] ports. #[must_use] - pub fn new(store: Arc) -> Self { - Self { store } + pub fn new(store: Arc, secret_store: Arc) -> Self { + Self { + store, + secret_store, + } } - /// Deletes the profile. + /// Deletes the profile (and its secret, if any). /// /// # Errors /// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on /// persistence failure. pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> { + let profiles = self.store.list().await?; + if let Some(profile) = profiles.into_iter().find(|p| p.id == input.id) { + if let Some(config) = &profile.opencode_provider { + self.secret_store.delete(&config.api_key_ref).await?; + } + } self.store.delete(input.id).await?; Ok(()) } diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 491f987..779851d 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -7,7 +7,7 @@ use domain::ports::{ AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError, - ProcessError, PtyError, RemoteError, RuntimeError, StoreError, + ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError, StoreError, }; use domain::{AgentId, NodeId}; use domain::{IssueStoreError, SprintStoreError}; @@ -236,6 +236,14 @@ impl From for AppError { } } +impl From for AppError { + /// Maps to [`AppError::Store`] — a [`domain::ports::SecretStore`] failure is a + /// persistence failure like any other store, coherent with [`StoreError`]. + fn from(e: SecretStoreError) -> Self { + Self::Store(e.to_string()) + } +} + impl From for AppError { /// Maps a structured [`AgentSessionError`] (ARCHITECTURE §17.1) onto the single /// application error shape. `Start`/`Io`/`Decode`/`Timeout` are all execution diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 99a7623..6340a1d 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -50,14 +50,16 @@ pub use agent::{ DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider, InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, - ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, - ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime, - PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext, - ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, - ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, - StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, - UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, - LIVE_STATE_INJECT_MAX, RESUME_PROMPT, + ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles, + ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, + LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry, + ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, + ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, + SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, + SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, + SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, + UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, + CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use background::{ BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 27e84be..3160c2e 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -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>>); + +#[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, 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(); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index c944491..47c7e7e 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -996,7 +996,8 @@ use application::{ CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput, FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput, - SaveProfileInput, SaveProfileOutput, + SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfileInput, + SaveProfileOutput, }; use domain::profile::{AgentProfile, OpenCodeConfig}; use domain::ProfileId; @@ -1043,6 +1044,40 @@ impl From for ProfileDto { } } +/// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenCodeProviderDto { + /// Identifier in the OpenCode provider registry (e.g. `"anthropic"`). + pub provider_id: String, + /// Human-readable label for the picker UI. + pub display_name: String, + /// Model names this provider serves, offered for selection. + pub models: Vec, +} + +impl From for OpenCodeProviderDto { + fn from(entry: application::OpenCodeProviderCatalogEntry) -> Self { + Self { + provider_id: entry.provider_id.to_owned(), + display_name: entry.display_name.to_owned(), + models: entry.models.iter().map(|&m| m.to_owned()).collect(), + } + } +} + +/// A list of OpenCode cloud-provider catalogue entries (camelCase array on the +/// wire). +#[derive(Debug, Clone, Serialize)] +#[serde(transparent)] +pub struct OpenCodeProviderListDto(pub Vec); + +impl From for OpenCodeProviderListDto { + fn from(out: application::ListOpenCodeProvidersOutput) -> Self { + Self(out.providers.into_iter().map(Into::into).collect()) + } +} + /// Request DTO for `detect_profiles`: the candidate profiles to probe. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1105,6 +1140,39 @@ impl From for SaveProfileInput { } } +/// Request DTO for `save_opencode_provider_profile` (ticket #92, lot B3): the +/// profile to upsert plus the literal provider fields. `apiKey` never reaches +/// `profiles.json` — the use case seals it into the `SecretStore`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveOpenCodeProviderProfileRequestDto { + /// The profile to create or replace (by id). + pub profile: AgentProfile, + /// Provider id in the OpenCode registry (e.g. `"anthropic"`). + pub provider_id: String, + /// Model name served by this provider. + pub model: String, + /// Literal API key, sealed into the `SecretStore` — never persisted as-is. + pub api_key: String, +} + +impl From for SaveOpenCodeProviderProfileInput { + fn from(dto: SaveOpenCodeProviderProfileRequestDto) -> Self { + Self { + profile: dto.profile, + provider_id: dto.provider_id, + model: dto.model, + api_key: dto.api_key, + } + } +} + +impl From for ProfileDto { + fn from(out: SaveOpenCodeProviderProfileOutput) -> Self { + Self(out.profile) + } +} + /// Request DTO for `clone_opencode_profile_from_seed`. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index ed93535..56fc0c2 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -27,8 +27,9 @@ use application::{ InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, - ListModelServers, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, + ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, + ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, + LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, @@ -39,7 +40,8 @@ use application::{ RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, - RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, + RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, + SaveProfile, SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, @@ -58,8 +60,8 @@ use domain::ports::{ IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, - Scheduler, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore, - ToolInvoker, WakeError, WakeReason, WindowStateStore, + Scheduler, SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, + TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -82,7 +84,8 @@ use infrastructure::{ FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore, - FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore, + FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsTemplateStore, + FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, @@ -925,6 +928,11 @@ pub struct BackendCore { pub list_profiles: Arc, /// Save (upsert) a profile. pub save_profile: Arc, + /// Save (upsert) an OpenCode profile backed by a cloud provider, sealing its + /// literal API key into the [`SecretStore`] (ticket #92, lot B3). + pub save_opencode_provider_profile: Arc, + /// Static catalogue of OpenCode cloud providers (ticket #92, lot B3). + pub list_opencode_providers: Arc, /// Create a new OpenCode profile instance from the canonical seed. pub clone_opencode_profile_from_seed: Arc, /// Delete a profile. @@ -1425,14 +1433,33 @@ impl BackendCore { let profile_store_port: Arc = Arc::clone(&profile_store) as Arc; + // Secret store (ticket #92, lot B2/B3): same app-data dir as `profiles.json`, + // but its own encrypted-at-rest files (`secrets.json` + `secret.key`) so an + // OpenCode cloud provider's API key never lands in plain JSON. + let secret_store = Arc::new(FsSecretStore::new( + Arc::clone(&fs_port), + app_data_dir.to_string_lossy().into_owned(), + )); + let secret_store_port: Arc = + Arc::clone(&secret_store) as Arc; + let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port))); let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_port))); let save_profile = Arc::new(SaveProfile::new(Arc::clone(&profile_store_port))); + let save_opencode_provider_profile = Arc::new(SaveOpenCodeProviderProfile::new( + Arc::clone(&profile_store_port), + Arc::clone(&secret_store_port), + Arc::clone(&ids) as Arc, + )); + let list_opencode_providers = Arc::new(ListOpenCodeProviders::new()); let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new( Arc::clone(&profile_store_port), Arc::clone(&ids) as Arc, )); - let delete_profile = Arc::new(DeleteProfile::new(Arc::clone(&profile_store_port))); + let delete_profile = Arc::new(DeleteProfile::new( + Arc::clone(&profile_store_port), + Arc::clone(&secret_store_port), + )); let configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port))); let reference_profiles = Arc::new(ReferenceProfiles::new()); let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port))); @@ -1587,6 +1614,7 @@ impl BackendCore { requester: requester.to_owned(), }) }), + Arc::clone(&secret_store_port), )) as Arc; let open_ticket_assistant = Arc::new(OpenTicketAssistant::new( Arc::clone(&issue_store_port), @@ -1814,7 +1842,8 @@ impl BackendCore { .with_live_state_lean(Arc::new(AppLiveStateLeanProvider { clock: Arc::clone(&clock) as Arc, }) as Arc) - .with_local_model_server(Arc::clone(&ensure_local_model_server)), + .with_local_model_server(Arc::clone(&ensure_local_model_server)) + .with_secret_store(Arc::clone(&secret_store_port)), ); // Inter-agent launcher: same context, memory, permissions and live-state @@ -1847,6 +1876,7 @@ impl BackendCore { clock: Arc::clone(&clock) as Arc, }) as Arc) .with_local_model_server(Arc::clone(&ensure_local_model_server)) + .with_secret_store(Arc::clone(&secret_store_port)) .with_structured( Arc::clone(&session_factory), Arc::clone(&structured_sessions), @@ -2587,6 +2617,8 @@ impl BackendCore { detect_profiles, list_profiles, save_profile, + save_opencode_provider_profile, + list_opencode_providers, clone_opencode_profile_from_seed, delete_profile, configure_profiles, diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 6d13d9f..b74e1c8 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1730,6 +1730,67 @@ pub trait WindowStateStore: Send + Sync { async fn load_window_state(&self) -> Result; } +/// 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) -> 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, 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). diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index e550de8..c925394 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -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, + model: impl Into, + api_key_ref: crate::ports::SecretRef, + ) -> Result { + 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, + /// 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, /// 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)); diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index c7176f4..14008de 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -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 diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs index 262f75d..dd6a957 100644 --- a/crates/infrastructure/src/assistant/mod.rs +++ b/crates/infrastructure/src/assistant/mod.rs @@ -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, mcp_runtime: Arc, + /// 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, } 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, app_data_dir: impl Into, runtime: Arc, mcp_runtime: Arc, + secret_store: Arc, ) -> 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 { + 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 { 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::>(); + 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, diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 6ed2e21..dc25270 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -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, }; diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 9dfd814..d3fac5b 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -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}; diff --git a/crates/infrastructure/src/store/secrets.rs b/crates/infrastructure/src/store/secrets.rs new file mode 100644 index 0000000..9e484ca --- /dev/null +++ b/crates/infrastructure/src/store/secrets.rs @@ -0,0 +1,320 @@ +//! [`FsSecretStore`] — encrypted-at-rest [`SecretStore`] adapter (ticket #92, lot B2). +//! +//! ```text +//! / +//! ├── secret.key # 32 raw bytes, AES-256-GCM key material, chmod 0600 (Unix) +//! └── secrets.json # { version, entries: { : "" } } +//! ``` +//! +//! 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, +} + +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 { + 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, + 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, app_data_dir: impl Into) -> 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 { + 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 { + 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 { + 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, 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()) + ); + } +} diff --git a/crates/infrastructure/tests/assistant_context_store.rs b/crates/infrastructure/tests/assistant_context_store.rs index 448178b..5eab2d9 100644 --- a/crates/infrastructure/tests/assistant_context_store.rs +++ b/crates/infrastructure/tests/assistant_context_store.rs @@ -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