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