Merge feature/ticket92-opencode-provider-cloud into develop
Ticket #92 — support des providers OpenCode cloud, backend + frontend, QA vert des deux côtés (cargo test workspace + 952 tests frontend). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1977,6 +1977,7 @@ dependencies = [
|
|||||||
"portable-pty",
|
"portable-pty",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"ring",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|||||||
@ -50,13 +50,15 @@ use crate::dto::{
|
|||||||
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
|
||||||
ModelServerConfigListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
|
||||||
ProfileListDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
|
||||||
|
ProjectMcpToolPermissionsDto,
|
||||||
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||||
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||||
SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
|
||||||
|
SetActiveLayoutRequestDto,
|
||||||
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
|
||||||
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
@ -1092,6 +1094,35 @@ pub async fn save_profile(
|
|||||||
.map_err(ErrorDto::from)
|
.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<OpenCodeProviderListDto, ErrorDto> {
|
||||||
|
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<ProfileDto, ErrorDto> {
|
||||||
|
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
|
/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance
|
||||||
/// from the canonical `opencode-llamacpp` seed/template.
|
/// from the canonical `opencode-llamacpp` seed/template.
|
||||||
///
|
///
|
||||||
|
|||||||
@ -249,6 +249,8 @@ pub fn run() {
|
|||||||
commands::detect_profiles,
|
commands::detect_profiles,
|
||||||
commands::list_profiles,
|
commands::list_profiles,
|
||||||
commands::save_profile,
|
commands::save_profile,
|
||||||
|
commands::save_opencode_provider_profile,
|
||||||
|
commands::list_opencode_providers,
|
||||||
commands::clone_opencode_profile_from_seed,
|
commands::clone_opencode_profile_from_seed,
|
||||||
commands::delete_profile,
|
commands::delete_profile,
|
||||||
commands::configure_profiles,
|
commands::configure_profiles,
|
||||||
|
|||||||
@ -17,10 +17,10 @@ use std::sync::Arc;
|
|||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
|
AgentContextStore, AgentRuntime, AgentSessionFactory, ContextInjectionPlan, EventBus,
|
||||||
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
|
FileSystem, FsError, IdGenerator, MemoryQuery, MemoryRecall, PermissionStore, PreparedContext,
|
||||||
ProfileStore, ProjectStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
|
ProfileStore, ProjectStore, PtyPort, RemotePath, SecretStore, SessionPlan, SkillStore,
|
||||||
StoreError,
|
SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
||||||
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan};
|
||||||
use domain::{
|
use domain::{
|
||||||
bound_handoff_summary, Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile,
|
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
|
/// `opencode.json` (B35). Optional for legacy tests/wiring; required at runtime
|
||||||
/// when `OpenCodeConfig.localModelServerId` is set.
|
/// when `OpenCodeConfig.localModelServerId` is set.
|
||||||
local_model_server: Option<Arc<EnsureLocalModelServer>>,
|
local_model_server: Option<Arc<EnsureLocalModelServer>>,
|
||||||
|
/// 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<Arc<dyn SecretStore>>,
|
||||||
/// Explicit intent for structured profiles when structured ports are absent.
|
/// Explicit intent for structured profiles when structured ports are absent.
|
||||||
structured_routing_mode: StructuredRoutingMode,
|
structured_routing_mode: StructuredRoutingMode,
|
||||||
}
|
}
|
||||||
@ -1191,6 +1197,7 @@ impl LaunchAgent {
|
|||||||
projectors: None,
|
projectors: None,
|
||||||
live_state_lean: None,
|
live_state_lean: None,
|
||||||
local_model_server: None,
|
local_model_server: None,
|
||||||
|
secret_store: None,
|
||||||
structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
|
structured_routing_mode: StructuredRoutingMode::HumanPtyFallback,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1209,6 +1216,17 @@ impl LaunchAgent {
|
|||||||
self
|
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<dyn SecretStore>) -> Self {
|
||||||
|
self.secret_store = Some(secret_store);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
|
/// 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
|
/// `# État du projet` (status + intent des autres agents) est injecté dans le
|
||||||
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
|
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
|
||||||
@ -1697,7 +1715,7 @@ impl LaunchAgent {
|
|||||||
input.mcp_runtime.as_ref(),
|
input.mcp_runtime.as_ref(),
|
||||||
&mut spec,
|
&mut spec,
|
||||||
)
|
)
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
// 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
|
// 5c. ── PROJECTION DES PERMISSIONS (lot LP3-3) ──
|
||||||
// Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
|
// Strictement APRÈS le convention file (5) ET la conf MCP (5a), donc
|
||||||
@ -2254,9 +2272,9 @@ impl LaunchAgent {
|
|||||||
project_root: &ProjectPath,
|
project_root: &ProjectPath,
|
||||||
runtime: Option<&McpRuntime>,
|
runtime: Option<&McpRuntime>,
|
||||||
spec: &mut SpawnSpec,
|
spec: &mut SpawnSpec,
|
||||||
) {
|
) -> Result<(), AppError> {
|
||||||
let Some(mcp) = &profile.mcp else {
|
let Some(mcp) = &profile.mcp else {
|
||||||
return;
|
return Ok(());
|
||||||
};
|
};
|
||||||
match &mcp.config {
|
match &mcp.config {
|
||||||
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
domain::profile::McpConfigStrategy::ConfigFile { target } => {
|
||||||
@ -2342,10 +2360,21 @@ impl LaunchAgent {
|
|||||||
}
|
}
|
||||||
domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
|
domain::profile::McpConfigStrategy::OpenCodeConfig { target } => {
|
||||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||||
return;
|
return Ok(());
|
||||||
}
|
}
|
||||||
let Some(opencode) = profile.opencode.as_ref() else {
|
let body = if let Some(opencode) = profile.opencode.as_ref() {
|
||||||
return;
|
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 config_path = join(run_dir, target);
|
||||||
let opencode_home = join(run_dir, ".opencode");
|
let opencode_home = join(run_dir, ".opencode");
|
||||||
@ -2361,8 +2390,6 @@ impl LaunchAgent {
|
|||||||
.create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
|
.create_dir_all(&RemotePath::new(format!("{}/{parent}", run_dir.as_str())))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let body =
|
|
||||||
opencode_config_json(opencode, project_root.as_str(), runtime).to_string();
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.fs
|
.fs
|
||||||
.write(&RemotePath::new(config_path.clone()), body.as_bytes())
|
.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()));
|
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<String, AppError> {
|
||||||
|
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(
|
async fn ensure_local_model_server_for_opencode(
|
||||||
@ -2679,6 +2739,66 @@ fn opencode_config_json(
|
|||||||
serde_json::Value::Object(root)
|
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::<Vec<_>>();
|
||||||
|
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
|
/// Builds an absolute path string by joining a [`ProjectPath`] with a relative
|
||||||
/// segment using a POSIX separator.
|
/// segment using a POSIX separator.
|
||||||
fn join(base: &ProjectPath, rel: &str) -> String {
|
fn join(base: &ProjectPath, rel: &str) -> String {
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
mod catalogue;
|
mod catalogue;
|
||||||
mod inspect;
|
mod inspect;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
|
mod provider_catalogue;
|
||||||
mod resume;
|
mod resume;
|
||||||
mod session_limit;
|
mod session_limit;
|
||||||
mod structured;
|
mod structured;
|
||||||
@ -28,6 +29,10 @@ pub use catalogue::{
|
|||||||
reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
|
reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS,
|
||||||
};
|
};
|
||||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||||
|
pub use provider_catalogue::{
|
||||||
|
opencode_provider_catalogue, ListOpenCodeProviders, ListOpenCodeProvidersOutput,
|
||||||
|
OpenCodeProviderCatalogEntry,
|
||||||
|
};
|
||||||
pub use lifecycle::{
|
pub use lifecycle::{
|
||||||
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
|
||||||
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
|
||||||
@ -46,5 +51,6 @@ pub use usecases::{
|
|||||||
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
ConfigureProfilesOutput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
||||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, ListProfiles,
|
||||||
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
ListProfilesOutput, ProfileAvailability, ReferenceProfiles, ReferenceProfilesOutput,
|
||||||
SaveProfile, SaveProfileInput, SaveProfileOutput,
|
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||||
|
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||||
};
|
};
|
||||||
|
|||||||
110
crates/application/src/agent/provider_catalogue.rs
Normal file
110
crates/application/src/agent/provider_catalogue.rs
Normal file
@ -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<OpenCodeProviderCatalogEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,8 +14,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ids::ProfileId;
|
use domain::ids::ProfileId;
|
||||||
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore};
|
use domain::ports::{AgentRuntime, IdGenerator, ProfileStore, SecretRef, SecretStore};
|
||||||
use domain::profile::{AgentProfile, OpenCodeConfig, StructuredAdapter};
|
use domain::profile::{AgentProfile, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter};
|
||||||
|
|
||||||
use crate::error::AppError;
|
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<dyn ProfileStore>,
|
||||||
|
secret_store: Arc<dyn SecretStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaveOpenCodeProviderProfile {
|
||||||
|
/// Builds the use case from the profile store, secret store and id generator
|
||||||
|
/// ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
profile_store: Arc<dyn ProfileStore>,
|
||||||
|
secret_store: Arc<dyn SecretStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
) -> 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<SaveOpenCodeProviderProfileOutput, AppError> {
|
||||||
|
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
|
// DeleteProfile
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -283,24 +372,37 @@ pub struct DeleteProfileInput {
|
|||||||
pub id: domain::ids::ProfileId,
|
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 {
|
pub struct DeleteProfile {
|
||||||
store: Arc<dyn ProfileStore>,
|
store: Arc<dyn ProfileStore>,
|
||||||
|
secret_store: Arc<dyn SecretStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeleteProfile {
|
impl DeleteProfile {
|
||||||
/// Builds the use case from the [`ProfileStore`] port.
|
/// Builds the use case from the [`ProfileStore`] and [`SecretStore`] ports.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(store: Arc<dyn ProfileStore>) -> Self {
|
pub fn new(store: Arc<dyn ProfileStore>, secret_store: Arc<dyn SecretStore>) -> Self {
|
||||||
Self { store }
|
Self {
|
||||||
|
store,
|
||||||
|
secret_store,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes the profile.
|
/// Deletes the profile (and its secret, if any).
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
|
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
|
||||||
/// persistence failure.
|
/// persistence failure.
|
||||||
pub async fn execute(&self, input: DeleteProfileInput) -> Result<(), AppError> {
|
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?;
|
self.store.delete(input.id).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
|
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
|
||||||
ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
|
ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError, StoreError,
|
||||||
};
|
};
|
||||||
use domain::{AgentId, NodeId};
|
use domain::{AgentId, NodeId};
|
||||||
use domain::{IssueStoreError, SprintStoreError};
|
use domain::{IssueStoreError, SprintStoreError};
|
||||||
@ -236,6 +236,14 @@ impl From<RemoteError> for AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<SecretStoreError> 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<AgentSessionError> for AppError {
|
impl From<AgentSessionError> for AppError {
|
||||||
/// Maps a structured [`AgentSessionError`] (ARCHITECTURE §17.1) onto the single
|
/// Maps a structured [`AgentSessionError`] (ARCHITECTURE §17.1) onto the single
|
||||||
/// application error shape. `Start`/`Io`/`Decode`/`Timeout` are all execution
|
/// application error shape. `Start`/`Io`/`Decode`/`Timeout` are all execution
|
||||||
|
|||||||
@ -50,14 +50,16 @@ pub use agent::{
|
|||||||
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
|
||||||
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||||
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
|
ListAgentsOutput, ListOpenCodeProviders, ListOpenCodeProvidersOutput, ListProfiles,
|
||||||
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
|
ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput,
|
||||||
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
|
LiveStateLeanProvider, McpRuntime, OpenCodeProviderCatalogEntry, PermissionProjectorRegistry,
|
||||||
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
|
ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
|
||||||
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
|
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent,
|
||||||
StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext,
|
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||||
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
|
||||||
|
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
|
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||||
};
|
};
|
||||||
pub use background::{
|
pub use background::{
|
||||||
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
|
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
|
||||||
|
|||||||
@ -15,8 +15,8 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use domain::ids::ProfileId;
|
use domain::ids::ProfileId;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SessionPlan, SpawnSpec,
|
AgentRuntime, IdGenerator, PreparedContext, ProfileStore, RuntimeError, SecretRef, SecretStore,
|
||||||
StoreError,
|
SecretStoreError, SessionPlan, SpawnSpec, StoreError,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, StructuredAdapter};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
@ -25,7 +25,8 @@ use application::{
|
|||||||
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
reference_profile_id, reference_profiles, CloneOpenCodeProfileFromSeed,
|
||||||
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
|
CloneOpenCodeProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile,
|
||||||
DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles,
|
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`.
|
/// Detection outcomes keyed by command. Missing keys ⇒ `false`.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum DetectResult {
|
enum DetectResult {
|
||||||
@ -392,7 +416,7 @@ async fn save_then_list_then_delete() {
|
|||||||
let store = FakeProfileStore::default();
|
let store = FakeProfileStore::default();
|
||||||
let save = SaveProfile::new(Arc::new(store.clone()));
|
let save = SaveProfile::new(Arc::new(store.clone()));
|
||||||
let list = ListProfiles::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 p = profile(1, "Claude", "claude");
|
||||||
let saved = save
|
let saved = save
|
||||||
@ -413,7 +437,7 @@ async fn save_then_list_then_delete() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_unknown_is_not_found_error() {
|
async fn delete_unknown_is_not_found_error() {
|
||||||
let store = FakeProfileStore::default();
|
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
|
let err = delete
|
||||||
.execute(DeleteProfileInput {
|
.execute(DeleteProfileInput {
|
||||||
id: ProfileId::from_uuid(uuid::Uuid::from_u128(123)),
|
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:?}");
|
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]
|
#[tokio::test]
|
||||||
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
|
async fn clone_opencode_profile_from_seed_creates_distinct_open_code_instance() {
|
||||||
let store = FakeProfileStore::default();
|
let store = FakeProfileStore::default();
|
||||||
|
|||||||
@ -996,7 +996,8 @@ use application::{
|
|||||||
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
|
CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
|
||||||
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
|
ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
|
||||||
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
|
FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
|
||||||
SaveProfileInput, SaveProfileOutput,
|
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfileInput,
|
||||||
|
SaveProfileOutput,
|
||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, OpenCodeConfig};
|
use domain::profile::{AgentProfile, OpenCodeConfig};
|
||||||
use domain::ProfileId;
|
use domain::ProfileId;
|
||||||
@ -1043,6 +1044,40 @@ impl From<CloneOpenCodeProfileFromSeedOutput> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<application::OpenCodeProviderCatalogEntry> 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<OpenCodeProviderDto>);
|
||||||
|
|
||||||
|
impl From<application::ListOpenCodeProvidersOutput> 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.
|
/// Request DTO for `detect_profiles`: the candidate profiles to probe.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -1105,6 +1140,39 @@ impl From<SaveProfileRequestDto> 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<SaveOpenCodeProviderProfileRequestDto> 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<SaveOpenCodeProviderProfileOutput> for ProfileDto {
|
||||||
|
fn from(out: SaveOpenCodeProviderProfileOutput) -> Self {
|
||||||
|
Self(out.profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `clone_opencode_profile_from_seed`.
|
/// Request DTO for `clone_opencode_profile_from_seed`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -27,8 +27,9 @@ use application::{
|
|||||||
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory,
|
||||||
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents,
|
||||||
ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories,
|
ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories,
|
||||||
ListModelServers, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects,
|
ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins,
|
||||||
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates,
|
||||||
|
LiveAgentRegistry, LiveSessions,
|
||||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||||
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
|
||||||
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||||
@ -39,7 +40,8 @@ use application::{
|
|||||||
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint,
|
||||||
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks,
|
||||||
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice,
|
||||||
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile,
|
||||||
|
SaveProfile, SessionLimitService,
|
||||||
SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents,
|
SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
||||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||||
@ -58,8 +60,8 @@ use domain::ports::{
|
|||||||
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||||
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
|
PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore,
|
||||||
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask,
|
PluginRegistryStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask,
|
||||||
Scheduler, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer, TemplateStore,
|
Scheduler, SecretStore, SkillStore, SprintStore, StructuredSessionEnvironmentPreparer,
|
||||||
ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
TemplateStore, ToolInvoker, WakeError, WakeReason, WindowStateStore,
|
||||||
};
|
};
|
||||||
use domain::profile::{
|
use domain::profile::{
|
||||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||||
@ -82,7 +84,8 @@ use infrastructure::{
|
|||||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
||||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
||||||
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
|
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
|
||||||
FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsWindowStateStore,
|
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsTemplateStore,
|
||||||
|
FsWindowStateStore,
|
||||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||||
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
|
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
|
||||||
@ -925,6 +928,11 @@ pub struct BackendCore {
|
|||||||
pub list_profiles: Arc<ListProfiles>,
|
pub list_profiles: Arc<ListProfiles>,
|
||||||
/// Save (upsert) a profile.
|
/// Save (upsert) a profile.
|
||||||
pub save_profile: Arc<SaveProfile>,
|
pub save_profile: Arc<SaveProfile>,
|
||||||
|
/// 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<SaveOpenCodeProviderProfile>,
|
||||||
|
/// Static catalogue of OpenCode cloud providers (ticket #92, lot B3).
|
||||||
|
pub list_opencode_providers: Arc<ListOpenCodeProviders>,
|
||||||
/// Create a new OpenCode profile instance from the canonical seed.
|
/// Create a new OpenCode profile instance from the canonical seed.
|
||||||
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
pub clone_opencode_profile_from_seed: Arc<CloneOpenCodeProfileFromSeed>,
|
||||||
/// Delete a profile.
|
/// Delete a profile.
|
||||||
@ -1425,14 +1433,33 @@ impl BackendCore {
|
|||||||
let profile_store_port: Arc<dyn ProfileStore> =
|
let profile_store_port: Arc<dyn ProfileStore> =
|
||||||
Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
|
Arc::clone(&profile_store) as Arc<dyn ProfileStore>;
|
||||||
|
|
||||||
|
// 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<dyn SecretStore> =
|
||||||
|
Arc::clone(&secret_store) as Arc<dyn SecretStore>;
|
||||||
|
|
||||||
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
|
let detect_profiles = Arc::new(DetectProfiles::new(Arc::clone(&runtime_port)));
|
||||||
let list_profiles = Arc::new(ListProfiles::new(Arc::clone(&profile_store_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_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<dyn IdGenerator>,
|
||||||
|
));
|
||||||
|
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
|
||||||
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
let clone_opencode_profile_from_seed = Arc::new(CloneOpenCodeProfileFromSeed::new(
|
||||||
Arc::clone(&profile_store_port),
|
Arc::clone(&profile_store_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
));
|
));
|
||||||
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 configure_profiles = Arc::new(ConfigureProfiles::new(Arc::clone(&profile_store_port)));
|
||||||
let reference_profiles = Arc::new(ReferenceProfiles::new());
|
let reference_profiles = Arc::new(ReferenceProfiles::new());
|
||||||
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
|
let first_run_state = Arc::new(FirstRunState::new(Arc::clone(&profile_store_port)));
|
||||||
@ -1587,6 +1614,7 @@ impl BackendCore {
|
|||||||
requester: requester.to_owned(),
|
requester: requester.to_owned(),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
Arc::clone(&secret_store_port),
|
||||||
)) as Arc<dyn StructuredSessionEnvironmentPreparer>;
|
)) as Arc<dyn StructuredSessionEnvironmentPreparer>;
|
||||||
let open_ticket_assistant = Arc::new(OpenTicketAssistant::new(
|
let open_ticket_assistant = Arc::new(OpenTicketAssistant::new(
|
||||||
Arc::clone(&issue_store_port),
|
Arc::clone(&issue_store_port),
|
||||||
@ -1814,7 +1842,8 @@ impl BackendCore {
|
|||||||
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
.with_live_state_lean(Arc::new(AppLiveStateLeanProvider {
|
||||||
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
}) as Arc<dyn LiveStateLeanProvider>)
|
}) as Arc<dyn LiveStateLeanProvider>)
|
||||||
.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
|
// Inter-agent launcher: same context, memory, permissions and live-state
|
||||||
@ -1847,6 +1876,7 @@ impl BackendCore {
|
|||||||
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
clock: Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
}) as Arc<dyn LiveStateLeanProvider>)
|
}) as Arc<dyn LiveStateLeanProvider>)
|
||||||
.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))
|
||||||
.with_structured(
|
.with_structured(
|
||||||
Arc::clone(&session_factory),
|
Arc::clone(&session_factory),
|
||||||
Arc::clone(&structured_sessions),
|
Arc::clone(&structured_sessions),
|
||||||
@ -2587,6 +2617,8 @@ impl BackendCore {
|
|||||||
detect_profiles,
|
detect_profiles,
|
||||||
list_profiles,
|
list_profiles,
|
||||||
save_profile,
|
save_profile,
|
||||||
|
save_opencode_provider_profile,
|
||||||
|
list_opencode_providers,
|
||||||
clone_opencode_profile_from_seed,
|
clone_opencode_profile_from_seed,
|
||||||
delete_profile,
|
delete_profile,
|
||||||
configure_profiles,
|
configure_profiles,
|
||||||
|
|||||||
@ -1730,6 +1730,67 @@ pub trait WindowStateStore: Send + Sync {
|
|||||||
async fn load_window_state(&self) -> Result<crate::layout::WindowStateSnapshot, StoreError>;
|
async fn load_window_state(&self) -> Result<crate::layout::WindowStateSnapshot, StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opaque lookup key for a value held in a [`SecretStore`] (ticket #92, lot B2).
|
||||||
|
/// Carries no semantics beyond "a stable reference" (e.g. a UUID string minted by
|
||||||
|
/// the caller) — the store never inspects or derives it. Attached to
|
||||||
|
/// [`crate::profile::OpenCodeProviderConfig`] and persisted as part of
|
||||||
|
/// `profiles.json`: safe, since it is only ever an opaque id, never the secret
|
||||||
|
/// value itself (see [`ProfileStore`]).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct SecretRef(pub String);
|
||||||
|
|
||||||
|
impl SecretRef {
|
||||||
|
/// Wraps an opaque identifier string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(id: impl Into<String>) -> Self {
|
||||||
|
Self(id.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the inner identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from the [`SecretStore`] port.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum SecretStoreError {
|
||||||
|
/// Underlying I/O error.
|
||||||
|
#[error("secret store io failed: {0}")]
|
||||||
|
Io(String),
|
||||||
|
/// Encryption/decryption failure (corrupt ciphertext, key mismatch, …).
|
||||||
|
#[error("secret store crypto failure: {0}")]
|
||||||
|
Crypto(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port for at-rest storage of secret string values (ticket #92, lot B2, cadrage
|
||||||
|
/// Architect §B2) — keeps literal API keys OUT of `profiles.json`, which is plain
|
||||||
|
/// JSON with no encryption. Adapters implementing this port are the ONLY place
|
||||||
|
/// allowed to hold the encryption key; callers only ever handle plaintext values
|
||||||
|
/// and opaque [`SecretRef`]s.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SecretStore: Send + Sync {
|
||||||
|
/// Stores (creates or replaces) the secret value under `key`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`SecretStoreError`] on I/O or encryption failure.
|
||||||
|
async fn put(&self, key: &SecretRef, value: &str) -> Result<(), SecretStoreError>;
|
||||||
|
|
||||||
|
/// Retrieves the secret value stored under `key`, or `None` if absent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`SecretStoreError`] on I/O or decryption failure.
|
||||||
|
async fn get(&self, key: &SecretRef) -> Result<Option<String>, SecretStoreError>;
|
||||||
|
|
||||||
|
/// Deletes the secret stored under `key`. Deleting an absent key is a no-op
|
||||||
|
/// success (idempotent).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`SecretStoreError`] on I/O failure.
|
||||||
|
async fn delete(&self, key: &SecretRef) -> Result<(), SecretStoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
|
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
|
||||||
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
|
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
|
||||||
/// single generic [`AgentRuntime`] adapter (Open/Closed).
|
/// single generic [`AgentRuntime`] adapter (Open/Closed).
|
||||||
|
|||||||
@ -354,6 +354,59 @@ impl OpenCodeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration déclarative d'un profil OpenCode piloté par un provider **cloud**
|
||||||
|
/// natif du registre OpenCode (ticket #92, lot B1).
|
||||||
|
///
|
||||||
|
/// Distincte de [`OpenCodeConfig`] : celle-ci sert le provider custom `llamacpp`
|
||||||
|
/// (endpoint local compatible OpenAI), celle-ci sert un provider BUILT-IN
|
||||||
|
/// d'OpenCode (Anthropic, OpenRouter, …) authentifié par une clé API littérale.
|
||||||
|
/// `AgentProfile::opencode_backend_is_consistent` garantit qu'un profil ne porte
|
||||||
|
/// jamais les deux configurations à la fois.
|
||||||
|
///
|
||||||
|
/// Contrairement à [`OpenCodeConfig::api_key`] (optionnelle, mode local sans
|
||||||
|
/// authentification), une clé est ici **obligatoire** : un provider cloud sans
|
||||||
|
/// clé ne peut pas être appelé. Elle n'est cependant jamais portée en littéral —
|
||||||
|
/// voir [`Self::api_key_ref`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OpenCodeProviderConfig {
|
||||||
|
/// Identifiant du provider dans le registre OpenCode (ex. `"anthropic"`,
|
||||||
|
/// `"openrouter"`). Doit correspondre à une entrée du catalogue (lot B3).
|
||||||
|
pub provider_id: String,
|
||||||
|
/// Nom du modèle servi par ce provider (ex. `"claude-sonnet-5"`).
|
||||||
|
pub model: String,
|
||||||
|
/// Référence opaque vers la clé API réelle, tenue par un
|
||||||
|
/// [`crate::ports::SecretStore`] (jamais un littéral en clair — ce struct est
|
||||||
|
/// persisté tel quel dans `profiles.json`, qui n'est pas chiffré). Seule une
|
||||||
|
/// couche application autorisée à manipuler le littéral (mint du `SecretRef` +
|
||||||
|
/// écriture dans le `SecretStore`) construit cette référence.
|
||||||
|
pub api_key_ref: crate::ports::SecretRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenCodeProviderConfig {
|
||||||
|
/// Construit une configuration validée (parse-don't-validate, comme
|
||||||
|
/// [`OpenCodeConfig::new`]). Ne prend jamais de clé littérale : seule une
|
||||||
|
/// référence déjà mintée par l'application est acceptée.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Renvoie [`DomainError::EmptyField`] si `provider_id` ou `model` est vide.
|
||||||
|
pub fn new(
|
||||||
|
provider_id: impl Into<String>,
|
||||||
|
model: impl Into<String>,
|
||||||
|
api_key_ref: crate::ports::SecretRef,
|
||||||
|
) -> Result<Self, DomainError> {
|
||||||
|
let provider_id = provider_id.into();
|
||||||
|
let model = model.into();
|
||||||
|
crate::validation::non_empty(&provider_id, "opencodeProvider.providerId")?;
|
||||||
|
crate::validation::non_empty(&model, "opencodeProvider.model")?;
|
||||||
|
Ok(Self {
|
||||||
|
provider_id,
|
||||||
|
model,
|
||||||
|
api_key_ref,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
|
/// Configuration HTTP d'un serveur de chat OpenAI-compatible.
|
||||||
///
|
///
|
||||||
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
|
/// Pure donnée domaine : l'endpoint est validé syntaxiquement mais jamais contacté,
|
||||||
@ -774,6 +827,17 @@ pub struct AgentProfile {
|
|||||||
/// HTTP OpenAI-compatible in-process.
|
/// HTTP OpenAI-compatible in-process.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub opencode: Option<OpenCodeConfig>,
|
pub opencode: Option<OpenCodeConfig>,
|
||||||
|
/// Configuration OpenCode **cloud** pour [`StructuredAdapter::OpenCode`] (ticket
|
||||||
|
/// #92, lot B1) : un provider BUILT-IN du registre OpenCode authentifié par clé
|
||||||
|
/// API, plutôt que le provider custom `llamacpp` de [`Self::opencode`]. Un profil
|
||||||
|
/// ne porte jamais les deux à la fois — voir
|
||||||
|
/// [`AgentProfile::opencode_backend_is_consistent`].
|
||||||
|
///
|
||||||
|
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
|
||||||
|
/// sérialisation : un profil sans provider cloud sérialise exactement comme
|
||||||
|
/// avant.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub opencode_provider: Option<OpenCodeProviderConfig>,
|
||||||
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
|
||||||
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
|
||||||
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
|
||||||
@ -979,6 +1043,7 @@ impl AgentProfile {
|
|||||||
structured_adapter: None,
|
structured_adapter: None,
|
||||||
chat_http: None,
|
chat_http: None,
|
||||||
opencode: None,
|
opencode: None,
|
||||||
|
opencode_provider: None,
|
||||||
mcp: None,
|
mcp: None,
|
||||||
liveness: None,
|
liveness: None,
|
||||||
rate_limit_pattern: None,
|
rate_limit_pattern: None,
|
||||||
@ -1011,6 +1076,14 @@ impl AgentProfile {
|
|||||||
self
|
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
|
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
|
||||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||||
/// profils sans MCP ne l'appellent simplement pas.
|
/// profils sans MCP ne l'appellent simplement pas.
|
||||||
@ -1079,6 +1152,17 @@ impl AgentProfile {
|
|||||||
self.structured_adapter.is_some()
|
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é
|
/// **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
|
/// × 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
|
/// `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);
|
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]
|
#[test]
|
||||||
fn opencode_config_serialises_local_model_server_id_camelcase() {
|
fn opencode_config_serialises_local_model_server_id_camelcase() {
|
||||||
let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));
|
let server_id = LocalModelServerId::from_uuid(uuid::Uuid::from_u128(35));
|
||||||
|
|||||||
@ -23,6 +23,10 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
hex = { 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) :
|
# 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
|
# 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
|
# vit ICI, jamais dans `domain` (qui reste dépendance-zéro). Version alignée sur
|
||||||
|
|||||||
@ -4,8 +4,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use application::McpRuntime;
|
use application::McpRuntime;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::SessionPlan;
|
use domain::ports::{SecretStore, SessionPlan};
|
||||||
use domain::profile::{McpConfigStrategy, StructuredAdapter};
|
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
||||||
use domain::{
|
use domain::{
|
||||||
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
|
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
|
||||||
ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring,
|
ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring,
|
||||||
@ -92,25 +92,57 @@ pub struct TicketAssistantEnvironmentPreparer {
|
|||||||
app_data_dir: String,
|
app_data_dir: String,
|
||||||
runtime: Arc<dyn AgentRuntime>,
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
|
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 {
|
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]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
fs: Arc<dyn FileSystem>,
|
fs: Arc<dyn FileSystem>,
|
||||||
app_data_dir: impl Into<String>,
|
app_data_dir: impl Into<String>,
|
||||||
runtime: Arc<dyn AgentRuntime>,
|
runtime: Arc<dyn AgentRuntime>,
|
||||||
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
|
mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
|
||||||
|
secret_store: Arc<dyn SecretStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
fs,
|
fs,
|
||||||
app_data_dir: app_data_dir.into(),
|
app_data_dir: app_data_dir.into(),
|
||||||
runtime,
|
runtime,
|
||||||
mcp_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> {
|
fn run_dir(&self, project: &Project, issue_ref: IssueRef) -> Result<ProjectPath, RuntimeError> {
|
||||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||||
let project_id = project.id.as_uuid().simple();
|
let project_id = project.id.as_uuid().simple();
|
||||||
@ -211,7 +243,19 @@ impl TicketAssistantEnvironmentPreparer {
|
|||||||
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
||||||
return Ok(());
|
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(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let config_path = join(cwd, target);
|
let config_path = join(cwd, target);
|
||||||
@ -222,8 +266,6 @@ impl TicketAssistantEnvironmentPreparer {
|
|||||||
for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
|
for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
|
||||||
self.create_dir(dir).await?;
|
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?;
|
self.write_file(&config_path, body.as_bytes()).await?;
|
||||||
env.extend([
|
env.extend([
|
||||||
("OPENCODE_CONFIG".to_owned(), config_path),
|
("OPENCODE_CONFIG".to_owned(), config_path),
|
||||||
@ -375,6 +417,77 @@ fn opencode_config_json(
|
|||||||
Value::Object(root)
|
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(
|
fn codex_config_toml(
|
||||||
existing: Option<&str>,
|
existing: Option<&str>,
|
||||||
mcp_declaration: &str,
|
mcp_declaration: &str,
|
||||||
|
|||||||
@ -102,7 +102,8 @@ pub use store::{
|
|||||||
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
||||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
||||||
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
|
FsMcpToolPermissionStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||||
FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore,
|
FsSecretStore, FsSkillStore, FsTemplateStore, FsWindowStateStore, HashEmbedder,
|
||||||
NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall,
|
||||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
|
||||||
|
VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,6 +14,7 @@ mod memory;
|
|||||||
mod permission;
|
mod permission;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod project;
|
mod project;
|
||||||
|
mod secrets;
|
||||||
mod skill;
|
mod skill;
|
||||||
mod template;
|
mod template;
|
||||||
mod vector;
|
mod vector;
|
||||||
@ -37,6 +38,7 @@ pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
|||||||
pub use permission::FsPermissionStore;
|
pub use permission::FsPermissionStore;
|
||||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||||
pub use project::FsProjectStore;
|
pub use project::FsProjectStore;
|
||||||
|
pub use secrets::FsSecretStore;
|
||||||
pub use skill::FsSkillStore;
|
pub use skill::FsSkillStore;
|
||||||
pub use template::FsTemplateStore;
|
pub use template::FsTemplateStore;
|
||||||
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};
|
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};
|
||||||
|
|||||||
320
crates/infrastructure/src/store/secrets.rs
Normal file
320
crates/infrastructure/src/store/secrets.rs
Normal 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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ use domain::{
|
|||||||
SpawnSpec,
|
SpawnSpec,
|
||||||
};
|
};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
FsAssistantContextStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
|
FsAssistantContextStore, FsSecretStore, LocalFileSystem, TicketAssistantEnvironmentPreparer,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -195,6 +195,7 @@ async fn environment_preparer_materialises_context_and_mcp_under_isolated_app_da
|
|||||||
requester: requester.clone(),
|
requester: requester.clone(),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
Arc::new(FsSecretStore::new(fs.clone(), app_data_dir.clone())),
|
||||||
);
|
);
|
||||||
|
|
||||||
let env = preparer
|
let env = preparer
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import type {
|
|||||||
MemoryType,
|
MemoryType,
|
||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
ModelServerCommandPreview,
|
ModelServerCommandPreview,
|
||||||
|
OpenCodeProviderCatalogEntry,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
Project,
|
Project,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
@ -61,6 +62,7 @@ import type {
|
|||||||
PermissionGateway,
|
PermissionGateway,
|
||||||
ProfileGateway,
|
ProfileGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
|
SaveOpenCodeProviderProfileInput,
|
||||||
SkillGateway,
|
SkillGateway,
|
||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
@ -181,6 +183,21 @@ export class HttpProfileGateway implements ProfileGateway {
|
|||||||
request: { name: input.name, opencode: input.opencode },
|
request: { name: input.name, opencode: input.opencode },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]> {
|
||||||
|
return this.http.invoke<OpenCodeProviderCatalogEntry[]>("list_opencode_providers");
|
||||||
|
}
|
||||||
|
saveOpenCodeProviderProfile(
|
||||||
|
input: SaveOpenCodeProviderProfileInput,
|
||||||
|
): Promise<AgentProfile> {
|
||||||
|
return this.http.invoke<AgentProfile>("save_opencode_provider_profile", {
|
||||||
|
request: {
|
||||||
|
profile: input.profile,
|
||||||
|
providerId: input.providerId,
|
||||||
|
model: input.model,
|
||||||
|
apiKey: input.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HttpModelServerGateway implements ModelServerGateway {
|
export class HttpModelServerGateway implements ModelServerGateway {
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import type {
|
|||||||
MemoryType,
|
MemoryType,
|
||||||
McpToolCatalogue,
|
McpToolCatalogue,
|
||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
|
OpenCodeProviderCatalogEntry,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
@ -99,6 +100,7 @@ import type {
|
|||||||
ReattachResult,
|
ReattachResult,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
ReviewPluginPackageInput,
|
ReviewPluginPackageInput,
|
||||||
|
SaveOpenCodeProviderProfileInput,
|
||||||
SkillGateway,
|
SkillGateway,
|
||||||
StoppedLiveAgent,
|
StoppedLiveAgent,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
@ -1272,6 +1274,20 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Static mock catalogue mirroring the backend OpenCode cloud-provider list. */
|
||||||
|
const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [
|
||||||
|
{
|
||||||
|
providerId: "anthropic",
|
||||||
|
displayName: "Anthropic",
|
||||||
|
models: ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
providerId: "openrouter",
|
||||||
|
displayName: "OpenRouter",
|
||||||
|
models: ["openrouter/auto", "qwen/qwen3-coder"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
|
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
|
||||||
* the wizard can be driven and tested fully offline. By default it reports the
|
* the wizard can be driven and tested fully offline. By default it reports the
|
||||||
@ -1342,6 +1358,30 @@ export class MockProfileGateway implements ProfileGateway {
|
|||||||
opencode: input.opencode ?? seed.opencode,
|
opencode: input.opencode ?? seed.opencode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]> {
|
||||||
|
return structuredClone(MOCK_OPENCODE_PROVIDERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveOpenCodeProviderProfile(
|
||||||
|
input: SaveOpenCodeProviderProfileInput,
|
||||||
|
): Promise<AgentProfile> {
|
||||||
|
const saved: AgentProfile = {
|
||||||
|
...structuredClone(input.profile),
|
||||||
|
opencode: undefined,
|
||||||
|
opencodeProvider: {
|
||||||
|
providerId: input.providerId,
|
||||||
|
model: input.model,
|
||||||
|
// The mock never seals a real secret; the ref is opaque either way.
|
||||||
|
apiKeyRef: `mock-secret-${input.profile.id}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const i = this.profiles.findIndex((p) => p.id === saved.id);
|
||||||
|
if (i >= 0) this.profiles[i] = saved;
|
||||||
|
else this.profiles.push(saved);
|
||||||
|
this.configured = true;
|
||||||
|
return structuredClone(saved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -8,8 +8,17 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain";
|
import type {
|
||||||
import type { CloneOpenCodeProfileFromSeedInput, ProfileGateway } from "@/ports";
|
AgentProfile,
|
||||||
|
FirstRunState,
|
||||||
|
OpenCodeProviderCatalogEntry,
|
||||||
|
ProfileAvailability,
|
||||||
|
} from "@/domain";
|
||||||
|
import type {
|
||||||
|
CloneOpenCodeProfileFromSeedInput,
|
||||||
|
ProfileGateway,
|
||||||
|
SaveOpenCodeProviderProfileInput,
|
||||||
|
} from "@/ports";
|
||||||
|
|
||||||
export class TauriProfileGateway implements ProfileGateway {
|
export class TauriProfileGateway implements ProfileGateway {
|
||||||
firstRunState(): Promise<FirstRunState> {
|
firstRunState(): Promise<FirstRunState> {
|
||||||
@ -51,4 +60,21 @@ export class TauriProfileGateway implements ProfileGateway {
|
|||||||
request: { name: input.name, opencode: input.opencode },
|
request: { name: input.name, opencode: input.opencode },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]> {
|
||||||
|
return invoke<OpenCodeProviderCatalogEntry[]>("list_opencode_providers");
|
||||||
|
}
|
||||||
|
|
||||||
|
saveOpenCodeProviderProfile(
|
||||||
|
input: SaveOpenCodeProviderProfileInput,
|
||||||
|
): Promise<AgentProfile> {
|
||||||
|
return invoke<AgentProfile>("save_opencode_provider_profile", {
|
||||||
|
request: {
|
||||||
|
profile: input.profile,
|
||||||
|
providerId: input.providerId,
|
||||||
|
model: input.model,
|
||||||
|
apiKey: input.apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -999,6 +999,38 @@ export interface OpenCodeConfig {
|
|||||||
localModelServerId?: string;
|
localModelServerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for an OpenCode profile backed by a **cloud** provider from the
|
||||||
|
* OpenCode registry (Anthropic, OpenRouter, …), as opposed to the custom
|
||||||
|
* `llamacpp` provider of {@link OpenCodeConfig}. Mirror of the backend
|
||||||
|
* `OpenCodeProviderConfig` (camelCase wire format). `apiKeyRef` is an opaque
|
||||||
|
* reference into the backend `SecretStore` — never the literal key, which is
|
||||||
|
* only ever sent (never read back) via
|
||||||
|
* {@link ProfileGateway.saveOpenCodeProviderProfile}.
|
||||||
|
*/
|
||||||
|
export interface OpenCodeProviderConfig {
|
||||||
|
/** Provider id in the OpenCode registry (e.g. `"anthropic"`). */
|
||||||
|
providerId: string;
|
||||||
|
/** Model name served by this provider. */
|
||||||
|
model: string;
|
||||||
|
/** Opaque reference to the sealed API key; never the literal key. */
|
||||||
|
apiKeyRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of the static OpenCode cloud-provider catalogue (mirror of the
|
||||||
|
* backend `OpenCodeProviderDto`), returned by
|
||||||
|
* {@link ProfileGateway.listOpenCodeProviders}.
|
||||||
|
*/
|
||||||
|
export interface OpenCodeProviderCatalogEntry {
|
||||||
|
/** Provider id in the OpenCode registry (e.g. `"anthropic"`). */
|
||||||
|
providerId: string;
|
||||||
|
/** Human-readable label for the picker UI. */
|
||||||
|
displayName: string;
|
||||||
|
/** Model names this provider serves, offered for selection. */
|
||||||
|
models: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
|
||||||
* UUID string; `detect` is the optional detection command line.
|
* UUID string; `detect` is the optional detection command line.
|
||||||
@ -1029,6 +1061,12 @@ export interface AgentProfile {
|
|||||||
chatHttp?: HttpChatConfig;
|
chatHttp?: HttpChatConfig;
|
||||||
/** OpenCode process-backed config. Present for `structuredAdapter: "openCode"`. */
|
/** OpenCode process-backed config. Present for `structuredAdapter: "openCode"`. */
|
||||||
opencode?: OpenCodeConfig;
|
opencode?: OpenCodeConfig;
|
||||||
|
/**
|
||||||
|
* OpenCode **cloud** provider config (ticket #92). Mutually exclusive with
|
||||||
|
* {@link opencode}: a profile is either local (`llamacpp`) or cloud, never
|
||||||
|
* both.
|
||||||
|
*/
|
||||||
|
opencodeProvider?: OpenCodeProviderConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
/** Availability of a candidate profile after detection (mirror of the DTO). */
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
|
within,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
|
|
||||||
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
||||||
@ -283,6 +284,152 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FirstRunWizard — OpenCode cloud provider (ticket #92)", () => {
|
||||||
|
const OPENCODE = "OpenCode + llama.cpp";
|
||||||
|
|
||||||
|
it("defaults to Local, and switching to Cloud swaps the sub-form", async () => {
|
||||||
|
renderWizard();
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
),
|
||||||
|
).toBe("true");
|
||||||
|
expect(screen.getByLabelText(`${OPENCODE} base url`)).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(`${OPENCODE} base url`)).toBeNull();
|
||||||
|
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editing an already-cloud profile preselects the Cloud segment with an empty key", async () => {
|
||||||
|
const profile = new MockProfileGateway();
|
||||||
|
await profile.configureProfiles([
|
||||||
|
{
|
||||||
|
id: "cfg-oc-cloud-1",
|
||||||
|
name: "Claude via OpenCode",
|
||||||
|
command: "opencode",
|
||||||
|
args: [],
|
||||||
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||||
|
detect: "opencode --version",
|
||||||
|
cwdTemplate: "{projectRoot}",
|
||||||
|
structuredAdapter: "openCode",
|
||||||
|
opencodeProvider: {
|
||||||
|
providerId: "anthropic",
|
||||||
|
model: "claude-sonnet-5",
|
||||||
|
apiKeyRef: "secret-ref-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const gateways = {
|
||||||
|
profile,
|
||||||
|
modelServer: new MockModelServerGateway(),
|
||||||
|
} as unknown as Gateways;
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<FirstRunWizard forceOpen />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
await waitForLoaded();
|
||||||
|
|
||||||
|
// Scope to this row: the still-unconfigured "OpenCode + llama.cpp"
|
||||||
|
// reference also renders (edit mode dedups by id only), with its own
|
||||||
|
// Local/Cloud segmented control.
|
||||||
|
const row = within(
|
||||||
|
screen.getByLabelText("use Claude via OpenCode").closest("li")!,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
row.getByRole("radio", { name: "Provider cloud" }).getAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
),
|
||||||
|
).toBe("true");
|
||||||
|
expect(
|
||||||
|
row.getByRole("radio", { name: "Local (llama.cpp)" }).getAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
),
|
||||||
|
).toBe("false");
|
||||||
|
expect(
|
||||||
|
(row.getByLabelText("Claude via OpenCode api key") as HTMLInputElement)
|
||||||
|
.value,
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascades provider ➜ model and disables Save until the API key is filled", async () => {
|
||||||
|
renderWizard();
|
||||||
|
await waitForLoaded();
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||||
|
|
||||||
|
const providerSelect = await screen.findByLabelText(`${OPENCODE} provider`);
|
||||||
|
const modelSelect = screen.getByLabelText(`${OPENCODE} model`) as HTMLSelectElement;
|
||||||
|
expect(modelSelect.disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(providerSelect, { target: { value: "anthropic" } });
|
||||||
|
expect(modelSelect.disabled).toBe(false);
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", {
|
||||||
|
name: "Enregistrer",
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(saveButton.disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(modelSelect, { target: { value: "claude-sonnet-5" } });
|
||||||
|
expect(saveButton.disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||||
|
target: { value: "sk-ant-secret" },
|
||||||
|
});
|
||||||
|
expect(saveButton.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a cloud profile via saveOpenCodeProviderProfile and clears the key afterwards", async () => {
|
||||||
|
const { profile } = renderWizard();
|
||||||
|
await waitForLoaded();
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByLabelText(`${OPENCODE} provider`), {
|
||||||
|
target: { value: "anthropic" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
|
||||||
|
target: { value: "claude-sonnet-5" },
|
||||||
|
});
|
||||||
|
const apiKeyInput = screen.getByLabelText(
|
||||||
|
`${OPENCODE} api key`,
|
||||||
|
) as HTMLInputElement;
|
||||||
|
fireEvent.change(apiKeyInput, { target: { value: "sk-ant-secret" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const saved = await profile.listProfiles();
|
||||||
|
const opencode = saved.find((p) => p.command === "opencode");
|
||||||
|
expect(opencode?.opencodeProvider?.providerId).toBe("anthropic");
|
||||||
|
expect(opencode?.opencodeProvider?.model).toBe("claude-sonnet-5");
|
||||||
|
expect(opencode?.opencode).toBeUndefined();
|
||||||
|
});
|
||||||
|
// The key is never kept around client-side once saved.
|
||||||
|
expect(apiKeyInput.value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows submit-time validation messages when provider/model/key are missing", async () => {
|
||||||
|
renderWizard();
|
||||||
|
await waitForLoaded();
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Provider cloud" }));
|
||||||
|
await screen.findByLabelText(`${OPENCODE} provider`);
|
||||||
|
|
||||||
|
// Save stays disabled with no key, so drive validation via a filled key but
|
||||||
|
// no provider/model to see the field-level messages fire on submit.
|
||||||
|
fireEvent.change(screen.getByLabelText(`${OPENCODE} api key`), {
|
||||||
|
target: { value: "sk-ant-secret" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Le provider est obligatoire.")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Le modèle est obligatoire.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
|
||||||
const OPENCODE = "OpenCode + llama.cpp";
|
const OPENCODE = "OpenCode + llama.cpp";
|
||||||
const CLONE1 = `${OPENCODE} (copy 1)`;
|
const CLONE1 = `${OPENCODE} (copy 1)`;
|
||||||
|
|||||||
@ -15,12 +15,17 @@
|
|||||||
* `./profile`.
|
* `./profile`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
|
GatewayError,
|
||||||
HttpChatConfig,
|
HttpChatConfig,
|
||||||
LocalModelServerConfig,
|
LocalModelServerConfig,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
|
OpenCodeProviderCatalogEntry,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||||
import {
|
import {
|
||||||
ModelServersPanel,
|
ModelServersPanel,
|
||||||
@ -41,6 +46,54 @@ function Caption({ children }: { children: React.ReactNode }) {
|
|||||||
return <span className="text-xs font-medium text-muted">{children}</span>;
|
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeError(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** View-model for the OpenCode cloud-provider catalogue (ticket #92). */
|
||||||
|
interface OpenCodeProviderCatalog {
|
||||||
|
providers: OpenCodeProviderCatalogEntry[] | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the static OpenCode cloud-provider catalogue once for the whole
|
||||||
|
* wizard (every Cloud row shares it), so the provider/model pickers can be
|
||||||
|
* populated. Exposes a `reload` for the blocking "Réessayer" state.
|
||||||
|
*/
|
||||||
|
function useOpenCodeProviderCatalog(): OpenCodeProviderCatalog {
|
||||||
|
const { profile } = useGateways();
|
||||||
|
const [providers, setProviders] = useState<OpenCodeProviderCatalogEntry[] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setProviders(await profile.listOpenCodeProviders());
|
||||||
|
} catch (e) {
|
||||||
|
setProviders(null);
|
||||||
|
setError(describeError(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { providers, loading, error, reload: () => void load() };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the wizard when it is the first run. Calls `onDone` once the user
|
* Renders the wizard when it is the first run. Calls `onDone` once the user
|
||||||
* finishes (so the host can drop the wizard and show the normal UI). Returns
|
* finishes (so the host can drop the wizard and show the normal UI). Returns
|
||||||
@ -62,6 +115,7 @@ export function FirstRunWizard({
|
|||||||
// which pre-loads and pre-selects the already-configured profiles.
|
// which pre-loads and pre-selects the already-configured profiles.
|
||||||
const vm = useFirstRun(forceOpen ? "edit" : "firstRun");
|
const vm = useFirstRun(forceOpen ? "edit" : "firstRun");
|
||||||
const modelServers = useModelServers();
|
const modelServers = useModelServers();
|
||||||
|
const providerCatalog = useOpenCodeProviderCatalog();
|
||||||
|
|
||||||
if (vm.isFirstRun === null) return null;
|
if (vm.isFirstRun === null) return null;
|
||||||
if (!forceOpen && vm.isFirstRun === false) return null;
|
if (!forceOpen && vm.isFirstRun === false) return null;
|
||||||
@ -129,6 +183,7 @@ export function FirstRunWizard({
|
|||||||
key={entry.profile.id}
|
key={entry.profile.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
servers={modelServers.servers}
|
servers={modelServers.servers}
|
||||||
|
providerCatalog={providerCatalog}
|
||||||
onToggle={() => vm.toggle(entry.profile.id)}
|
onToggle={() => vm.toggle(entry.profile.id)}
|
||||||
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
||||||
onRemove={() => vm.remove(entry.profile.id)}
|
onRemove={() => vm.remove(entry.profile.id)}
|
||||||
@ -159,6 +214,7 @@ export function FirstRunWizard({
|
|||||||
function ProfileRow({
|
function ProfileRow({
|
||||||
entry,
|
entry,
|
||||||
servers,
|
servers,
|
||||||
|
providerCatalog,
|
||||||
onToggle,
|
onToggle,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
@ -167,6 +223,8 @@ function ProfileRow({
|
|||||||
entry: WizardEntry;
|
entry: WizardEntry;
|
||||||
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
|
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
|
||||||
servers: LocalModelServerConfig[];
|
servers: LocalModelServerConfig[];
|
||||||
|
/** OpenCode cloud-provider catalogue (ticket #92), shared across rows. */
|
||||||
|
providerCatalog: OpenCodeProviderCatalog;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onChange: (p: AgentProfile) => void;
|
onChange: (p: AgentProfile) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@ -263,6 +321,77 @@ function ProfileRow({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{profile.structuredAdapter === "openCode" && (
|
{profile.structuredAdapter === "openCode" && (
|
||||||
|
<OpenCodeModeFields
|
||||||
|
profile={profile}
|
||||||
|
errors={errors}
|
||||||
|
servers={servers}
|
||||||
|
providerCatalog={providerCatalog}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segmented control (F — ticket #92) choosing whether an OpenCode profile runs
|
||||||
|
* against the local `llama.cpp` endpoint or a cloud provider from the OpenCode
|
||||||
|
* registry, and renders the matching sub-form. The two sub-forms never overlap;
|
||||||
|
* switching segments keeps the inactive one's draft in memory (component-local
|
||||||
|
* state) without touching `profile` until it is actually submitted.
|
||||||
|
*/
|
||||||
|
function OpenCodeModeFields({
|
||||||
|
profile,
|
||||||
|
errors,
|
||||||
|
servers,
|
||||||
|
providerCatalog,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
profile: AgentProfile;
|
||||||
|
errors: ProfileErrors;
|
||||||
|
servers: LocalModelServerConfig[];
|
||||||
|
providerCatalog: OpenCodeProviderCatalog;
|
||||||
|
onChange: (p: AgentProfile) => void;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<"local" | "cloud">(
|
||||||
|
profile.opencodeProvider ? "cloud" : "local",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="mode du profil OpenCode"
|
||||||
|
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: "local", label: "Local (llama.cpp)" },
|
||||||
|
{ id: "cloud", label: "Provider cloud" },
|
||||||
|
] as const
|
||||||
|
).map((seg) => {
|
||||||
|
const active = mode === seg.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={seg.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => setMode(seg.id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary text-on-primary"
|
||||||
|
: "text-muted hover:text-content",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{seg.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "local" && (
|
||||||
<OpenCodeFields
|
<OpenCodeFields
|
||||||
profile={profile}
|
profile={profile}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
@ -270,7 +399,223 @@ function ProfileRow({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
|
||||||
|
{mode === "cloud" && (
|
||||||
|
<OpenCodeProviderFields
|
||||||
|
profile={profile}
|
||||||
|
catalog={providerCatalog}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field-keyed validation errors for the Cloud sub-form, surfaced on submit. */
|
||||||
|
interface CloudFieldErrors {
|
||||||
|
providerId?: string;
|
||||||
|
model?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OpenCode **cloud** provider config section (ticket #92): provider ➜
|
||||||
|
* model (cascading selects fed by the static catalogue) ➜ API key. The key is
|
||||||
|
* never pre-filled (create or edit) since the backend never returns it — it
|
||||||
|
* resigns whatever literal it receives on every save, so editing an existing
|
||||||
|
* cloud profile requires re-entering it every time (§3 of the spec).
|
||||||
|
*/
|
||||||
|
function OpenCodeProviderFields({
|
||||||
|
profile,
|
||||||
|
catalog,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
profile: AgentProfile;
|
||||||
|
catalog: OpenCodeProviderCatalog;
|
||||||
|
onChange: (p: AgentProfile) => void;
|
||||||
|
}) {
|
||||||
|
const { profile: profileGateway } = useGateways();
|
||||||
|
const existing = profile.opencodeProvider;
|
||||||
|
const [providerId, setProviderId] = useState(existing?.providerId ?? "");
|
||||||
|
const [model, setModel] = useState(existing?.model ?? "");
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<CloudFieldErrors>({});
|
||||||
|
|
||||||
|
const isEditing = existing !== undefined;
|
||||||
|
const models =
|
||||||
|
catalog.providers?.find((p) => p.providerId === providerId)?.models ?? [];
|
||||||
|
const catalogReady = catalog.providers !== null && !catalog.loading;
|
||||||
|
const saveDisabled =
|
||||||
|
saving || !catalogReady || Boolean(catalog.error) || apiKey.length === 0;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const errors: CloudFieldErrors = {};
|
||||||
|
if (providerId.length === 0) errors.providerId = "Le provider est obligatoire.";
|
||||||
|
if (model.length === 0) errors.model = "Le modèle est obligatoire.";
|
||||||
|
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
|
||||||
|
setFieldErrors(errors);
|
||||||
|
if (Object.keys(errors).length > 0) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
try {
|
||||||
|
const saved = await profileGateway.saveOpenCodeProviderProfile({
|
||||||
|
profile,
|
||||||
|
providerId,
|
||||||
|
model,
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
onChange(saved);
|
||||||
|
setApiKey("");
|
||||||
|
} catch (e) {
|
||||||
|
setSaveError(describeError(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
|
||||||
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
|
Provider cloud (OpenCode)
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{saveError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{catalog.loading && (
|
||||||
|
<p className="text-xs text-faint">Chargement des providers…</p>
|
||||||
|
)}
|
||||||
|
{catalog.error && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
Impossible de charger la liste des providers cloud.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Provider</Caption>
|
||||||
|
<select
|
||||||
|
aria-label={`${profile.name} provider`}
|
||||||
|
value={providerId}
|
||||||
|
disabled={!catalogReady}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProviderId(e.target.value);
|
||||||
|
setModel("");
|
||||||
|
setFieldErrors((prev) => ({ ...prev, providerId: undefined }));
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
fieldErrors.providerId ? "border-danger" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{catalog.loading ? "Chargement des providers…" : "Choisir un provider…"}
|
||||||
|
</option>
|
||||||
|
{catalog.providers?.map((p) => (
|
||||||
|
<option key={p.providerId} value={p.providerId}>
|
||||||
|
{p.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{fieldErrors.providerId && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.providerId}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Modèle</Caption>
|
||||||
|
<select
|
||||||
|
aria-label={`${profile.name} model`}
|
||||||
|
value={model}
|
||||||
|
disabled={providerId.length === 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
setModel(e.target.value);
|
||||||
|
setFieldErrors((prev) => ({ ...prev, model: undefined }));
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-full rounded-md border bg-raised px-3 text-sm text-content outline-none",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
fieldErrors.model ? "border-danger" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{providerId.length === 0 ? "—" : "Choisir un modèle…"}
|
||||||
|
</option>
|
||||||
|
{models.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{fieldErrors.model && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.model}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Clé API</Caption>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
aria-label={`${profile.name} api key`}
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
value={apiKey}
|
||||||
|
placeholder={
|
||||||
|
isEditing
|
||||||
|
? "Ressaisissez la clé API pour confirmer l'enregistrement"
|
||||||
|
: "ex. sk-ant-…"
|
||||||
|
}
|
||||||
|
invalid={Boolean(fieldErrors.apiKey)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiKey(e.target.value);
|
||||||
|
setFieldErrors((prev) => ({ ...prev, apiKey: undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
aria-label="afficher/masquer la clé API"
|
||||||
|
onClick={() => setShowKey((v) => !v)}
|
||||||
|
>
|
||||||
|
{showKey ? "🙈" : "👁"}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{fieldErrors.apiKey && (
|
||||||
|
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
|
||||||
|
)}
|
||||||
|
<small className="text-xs text-faint">
|
||||||
|
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
|
||||||
|
chiffrée localement.
|
||||||
|
</small>
|
||||||
|
{isEditing && (
|
||||||
|
<small className="text-xs text-muted">
|
||||||
|
Pour des raisons de sécurité, la clé n'est jamais réaffichée :
|
||||||
|
ressaisissez-la à chaque modification de ce profil.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={saving}
|
||||||
|
disabled={saveDisabled}
|
||||||
|
onClick={() => void save()}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -144,9 +144,11 @@ export function validateProfile(p: AgentProfile): ProfileErrors {
|
|||||||
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
|
Object.assign(errors, validateHttpChatConfig(p.chatHttp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An OpenCode profile carries its own llama.cpp endpoint config; the backend
|
// An OpenCode profile carries its own llama.cpp endpoint config (local mode)
|
||||||
// refuses to persist it unless base URL + model are well-formed, so mirror that.
|
// or a cloud provider config (`opencodeProvider`, ticket #92) — never both,
|
||||||
if (p.structuredAdapter === "openCode") {
|
// and the cloud sub-form owns its own submit-time validation (provider,
|
||||||
|
// model, API key), so only the local-mode shape is mirrored here.
|
||||||
|
if (p.structuredAdapter === "openCode" && !p.opencodeProvider) {
|
||||||
if (!p.opencode) {
|
if (!p.opencode) {
|
||||||
errors.baseURL = "Base URL must start with http:// or https://.";
|
errors.baseURL = "Base URL must start with http:// or https://.";
|
||||||
errors.model = "Model is required.";
|
errors.model = "Model is required.";
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import type {
|
|||||||
MemoryType,
|
MemoryType,
|
||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
|
OpenCodeProviderCatalogEntry,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
@ -671,6 +672,21 @@ export interface ProfileGateway {
|
|||||||
cloneOpenCodeProfileFromSeed(
|
cloneOpenCodeProfileFromSeed(
|
||||||
input?: CloneOpenCodeProfileFromSeedInput,
|
input?: CloneOpenCodeProfileFromSeedInput,
|
||||||
): Promise<AgentProfile>;
|
): Promise<AgentProfile>;
|
||||||
|
/**
|
||||||
|
* Static catalogue of OpenCode cloud providers (ticket #92), for the
|
||||||
|
* provider/model pickers of the Cloud sub-form.
|
||||||
|
*/
|
||||||
|
listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]>;
|
||||||
|
/**
|
||||||
|
* Creates or replaces (by id) an OpenCode profile in **cloud** mode (ticket
|
||||||
|
* #92). Unlike {@link saveProfile}, this takes the literal API key: the
|
||||||
|
* backend seals it into the `SecretStore` and never returns it — the
|
||||||
|
* returned profile's `opencodeProvider` only ever carries `providerId` +
|
||||||
|
* `model` (+ the opaque `apiKeyRef`), never the literal key.
|
||||||
|
*/
|
||||||
|
saveOpenCodeProviderProfile(
|
||||||
|
input: SaveOpenCodeProviderProfileInput,
|
||||||
|
): Promise<AgentProfile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */
|
/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */
|
||||||
@ -681,6 +697,18 @@ export interface CloneOpenCodeProfileFromSeedInput {
|
|||||||
opencode?: OpenCodeConfig;
|
opencode?: OpenCodeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Input for {@link ProfileGateway.saveOpenCodeProviderProfile}. */
|
||||||
|
export interface SaveOpenCodeProviderProfileInput {
|
||||||
|
/** The profile to create or replace (by id). */
|
||||||
|
profile: AgentProfile;
|
||||||
|
/** Provider id in the OpenCode registry (e.g. `"anthropic"`). */
|
||||||
|
providerId: string;
|
||||||
|
/** Model name served by this provider. */
|
||||||
|
model: string;
|
||||||
|
/** Literal API key — sealed into the `SecretStore`, never persisted as-is. */
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local model servers (F35). CRUD over the global registry of declared
|
* Local model servers (F35). CRUD over the global registry of declared
|
||||||
* `llama.cpp` servers an OpenCode profile can bind to via
|
* `llama.cpp` servers an OpenCode profile can bind to via
|
||||||
|
|||||||
Reference in New Issue
Block a user