- agent/lifecycle.rs: lifecycle management per profile
- agent/provider_catalogue.rs: provider registration with model support
- agent/usecases.rs: usecases for profile-based agent invocation
- agent/mod.rs: expose agent capabilities via AgentManager
- backend/dto.rs: AgentModelConfig, AgentProviderConfig DTOs
- domain/profile.rs: extend Profile avec agent capabilities
- domain/permission.rs: permission checks pour agent access
- infrastructure/assistant/mod.rs: agent integration
- infrastructure/permission/{claude,codex}.rs: permission handlers
- web-server/lib.rs: agent endpoints
- commands.rs: agent commands
- frontend/adapters/{http,profile,mock,domain}.ts: adapters
- frontend/first-run/FirstRunWizard.{test.tsx,tsx}: first-run flow
808 lines
29 KiB
Rust
808 lines
29 KiB
Rust
//! Ticket assistant infrastructure adapters.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use application::McpRuntime;
|
|
use async_trait::async_trait;
|
|
use domain::ports::{SecretStore, SessionPlan};
|
|
use domain::profile::{McpConfigStrategy, OpenCodeProviderConfig, StructuredAdapter};
|
|
use domain::{
|
|
AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
|
|
ContextInjectionPlan, EffectivePermissions, FileSystem, FsError, Issue, IssueRef, MarkdownDoc,
|
|
McpServerWiring, PreparedContext, Project, ProjectPath, RemotePath, RuntimeError,
|
|
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer,
|
|
};
|
|
use serde_json::{json, Map, Value};
|
|
|
|
const ASSISTANT_DIR: &str = "assistant";
|
|
const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md";
|
|
const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md");
|
|
|
|
/// Resolves the live MCP runtime facts for a ticket assistant requester.
|
|
pub type TicketAssistantMcpRuntimeResolver =
|
|
dyn Fn(&Project, &str) -> Option<McpRuntime> + Send + Sync;
|
|
|
|
/// File-backed provider for the IdeA-owned ticket assistant context.
|
|
#[derive(Clone)]
|
|
pub struct FsAssistantContextStore {
|
|
fs: Arc<dyn FileSystem>,
|
|
app_data_dir: String,
|
|
}
|
|
|
|
impl FsAssistantContextStore {
|
|
/// Builds the store from an injected filesystem and app-data directory.
|
|
#[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 join(&self, rel: &str) -> String {
|
|
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
|
format!("{base}/{rel}")
|
|
}
|
|
|
|
fn context_path(&self) -> RemotePath {
|
|
RemotePath::new(self.join(&format!("{ASSISTANT_DIR}/{TICKET_ASSISTANT_FILE}")))
|
|
}
|
|
|
|
async fn read_base_context(&self) -> Result<String, AssistantContextError> {
|
|
let path = self.context_path();
|
|
match self.fs.read(&path).await {
|
|
Ok(bytes) => {
|
|
String::from_utf8(bytes).map_err(|e| AssistantContextError::Store(e.to_string()))
|
|
}
|
|
Err(FsError::NotFound(_)) => Ok(DEFAULT_TICKET_ASSISTANT_CONTEXT.to_owned()),
|
|
Err(e) => Err(AssistantContextError::Store(e.to_string())),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AssistantContextProvider for FsAssistantContextStore {
|
|
async fn prepare_ticket_assistant_context(
|
|
&self,
|
|
project: &Project,
|
|
issue: &Issue,
|
|
) -> Result<PreparedContext, AssistantContextError> {
|
|
let mut content = self.read_base_context().await?;
|
|
content.push_str("\n\n## Bound Ticket\n\n");
|
|
content.push_str(&format!("- Ref: {}\n", issue.reference()));
|
|
content.push_str(&format!("- Title: {}\n", issue.title));
|
|
content.push_str("\n### Description\n\n");
|
|
content.push_str(issue.description.as_str());
|
|
content.push_str("\n\n### Carnet\n\n");
|
|
content.push_str(issue.carnet.as_str());
|
|
content.push('\n');
|
|
|
|
Ok(PreparedContext {
|
|
content: MarkdownDoc::new(content),
|
|
relative_path: TICKET_ASSISTANT_FILE.to_owned(),
|
|
project_root: project.root.as_str().to_owned(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// File-backed environment preparer for structured ticket assistant sessions.
|
|
#[derive(Clone)]
|
|
pub struct TicketAssistantEnvironmentPreparer {
|
|
fs: Arc<dyn FileSystem>,
|
|
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, 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();
|
|
let issue_number = issue_ref.number().get();
|
|
ProjectPath::new(format!(
|
|
"{base}/{ASSISTANT_DIR}/tickets/{project_id}/{issue_number}"
|
|
))
|
|
.map_err(|e| RuntimeError::Invocation(e.to_string()))
|
|
}
|
|
|
|
async fn create_dir(&self, path: &str) -> Result<(), RuntimeError> {
|
|
self.fs
|
|
.create_dir_all(&RemotePath::new(path.to_owned()))
|
|
.await
|
|
.map_err(|e| RuntimeError::Invocation(e.to_string()))
|
|
}
|
|
|
|
async fn write_file(&self, path: &str, bytes: &[u8]) -> Result<(), RuntimeError> {
|
|
if let Some(parent) = parent_abs(path) {
|
|
self.create_dir(parent).await?;
|
|
}
|
|
self.fs
|
|
.write(&RemotePath::new(path.to_owned()), bytes)
|
|
.await
|
|
.map_err(|e| RuntimeError::Invocation(e.to_string()))
|
|
}
|
|
|
|
async fn read_optional(&self, path: &str) -> Result<Option<String>, RuntimeError> {
|
|
match self.fs.read(&RemotePath::new(path.to_owned())).await {
|
|
Ok(bytes) => String::from_utf8(bytes)
|
|
.map(Some)
|
|
.map_err(|e| RuntimeError::Invocation(e.to_string())),
|
|
Err(FsError::NotFound(_)) => Ok(None),
|
|
Err(e) => Err(RuntimeError::Invocation(e.to_string())),
|
|
}
|
|
}
|
|
|
|
async fn materialise_context(
|
|
&self,
|
|
plan: Option<ContextInjectionPlan>,
|
|
cwd: &ProjectPath,
|
|
prepared: &PreparedContext,
|
|
env: &mut Vec<(String, String)>,
|
|
) -> Result<(), RuntimeError> {
|
|
match plan {
|
|
Some(ContextInjectionPlan::File { target }) => {
|
|
self.write_file(&join(cwd, &target), prepared.content.as_str().as_bytes())
|
|
.await?;
|
|
}
|
|
Some(ContextInjectionPlan::Env { var }) => {
|
|
let path = join(cwd, &prepared.relative_path);
|
|
self.write_file(&path, prepared.content.as_str().as_bytes())
|
|
.await?;
|
|
env.push((var, path));
|
|
}
|
|
Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {
|
|
let path = join(cwd, &prepared.relative_path);
|
|
self.write_file(&path, prepared.content.as_str().as_bytes())
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn materialise_mcp(
|
|
&self,
|
|
project: &Project,
|
|
profile: &AgentProfile,
|
|
cwd: &ProjectPath,
|
|
requester: &str,
|
|
env: &mut Vec<(String, String)>,
|
|
) -> Result<(), RuntimeError> {
|
|
let Some(mcp) = &profile.mcp else {
|
|
return Ok(());
|
|
};
|
|
let runtime = (self.mcp_runtime)(project, requester);
|
|
match &mcp.config {
|
|
McpConfigStrategy::ConfigFile { target } => {
|
|
let declaration = mcp_server_wiring(mcp.transport, runtime.as_ref()).to_mcp_json();
|
|
self.write_file(&join(cwd, target), declaration.as_bytes())
|
|
.await?;
|
|
}
|
|
McpConfigStrategy::TomlConfigHome { target, home_env } => {
|
|
let path = join(cwd, target);
|
|
let declaration =
|
|
mcp_server_wiring(mcp.transport, runtime.as_ref()).to_config_toml();
|
|
let existing = self.read_optional(&path).await?;
|
|
let rendered = codex_config_toml(
|
|
existing.as_deref(),
|
|
&declaration,
|
|
cwd.as_str(),
|
|
project.root.as_str(),
|
|
profile.model.as_deref(),
|
|
);
|
|
self.write_file(&path, rendered.as_bytes()).await?;
|
|
env.push((home_env.clone(), parent_dir(cwd, target)));
|
|
}
|
|
McpConfigStrategy::OpenCodeConfig { target } => {
|
|
if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
|
|
return Ok(());
|
|
}
|
|
// No `PermissionStore` is wired for ticket-assistant sessions (no
|
|
// per-agent policy exists in this path, for any CLI) — `eff` is
|
|
// always `None` here, which per `opencode_permission_block`'s
|
|
// contract omits the `permission` key entirely and preserves
|
|
// OpenCode's native prompting, matching Claude/Codex's behaviour
|
|
// on this same path.
|
|
let body = if let Some(opencode) = profile.opencode.as_ref() {
|
|
opencode_config_json(opencode, project.root.as_str(), runtime.as_ref(), None)
|
|
.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(),
|
|
None,
|
|
)
|
|
.to_string()
|
|
} else {
|
|
return Ok(());
|
|
};
|
|
let config_path = join(cwd, target);
|
|
let opencode_home = join(cwd, ".opencode");
|
|
let xdg_config = format!("{opencode_home}/config");
|
|
let xdg_data = format!("{opencode_home}/data");
|
|
let xdg_cache = format!("{opencode_home}/cache");
|
|
for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
|
|
self.create_dir(dir).await?;
|
|
}
|
|
self.write_file(&config_path, body.as_bytes()).await?;
|
|
env.extend([
|
|
("OPENCODE_CONFIG".to_owned(), config_path),
|
|
("HOME".to_owned(), opencode_home),
|
|
("XDG_CONFIG_HOME".to_owned(), xdg_config),
|
|
("XDG_DATA_HOME".to_owned(), xdg_data),
|
|
("XDG_CACHE_HOME".to_owned(), xdg_cache),
|
|
("OPENCODE_DISABLE_AUTOUPDATE".to_owned(), "1".to_owned()),
|
|
]);
|
|
}
|
|
McpConfigStrategy::Flag { flag } => {
|
|
env.push((flag.clone(), cwd.as_str().to_owned()));
|
|
}
|
|
McpConfigStrategy::Env { var } => {
|
|
env.push((var.clone(), cwd.as_str().to_owned()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer {
|
|
async fn prepare_ticket_assistant(
|
|
&self,
|
|
project: &Project,
|
|
issue_ref: IssueRef,
|
|
profile: &AgentProfile,
|
|
prepared: &PreparedContext,
|
|
requester: &str,
|
|
) -> Result<StructuredSessionEnvironment, RuntimeError> {
|
|
let run_dir = self.run_dir(project, issue_ref)?;
|
|
self.create_dir(run_dir.as_str()).await?;
|
|
let spec =
|
|
self.runtime
|
|
.prepare_invocation(profile, prepared, &run_dir, &SessionPlan::None)?;
|
|
let mut env = spec.env;
|
|
self.materialise_context(spec.context_plan, &spec.cwd, prepared, &mut env)
|
|
.await?;
|
|
self.materialise_mcp(project, profile, &spec.cwd, requester, &mut env)
|
|
.await?;
|
|
Ok(StructuredSessionEnvironment {
|
|
cwd: spec.cwd,
|
|
env,
|
|
structured_policy: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn mcp_server_wiring(
|
|
transport: domain::profile::McpTransport,
|
|
runtime: Option<&McpRuntime>,
|
|
) -> McpServerWiring {
|
|
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()]),
|
|
};
|
|
McpServerWiring::new(command, args, transport)
|
|
}
|
|
|
|
fn opencode_config_json(
|
|
config: &domain::profile::OpenCodeConfig,
|
|
project_root: &str,
|
|
runtime: Option<&McpRuntime>,
|
|
eff: Option<&EffectivePermissions>,
|
|
) -> Value {
|
|
let model = config.model.as_str();
|
|
let opencode_model = format!("llamacpp/{model}");
|
|
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(opencode_model.clone()));
|
|
|
|
let mut options = Map::new();
|
|
options.insert("baseURL".to_owned(), Value::String(config.base_url.clone()));
|
|
if let Some(api_key) = config.api_key.as_ref() {
|
|
options.insert("apiKey".to_owned(), Value::String(api_key.clone()));
|
|
}
|
|
|
|
root.insert(
|
|
"provider".to_owned(),
|
|
json!({
|
|
"llamacpp": {
|
|
"npm": "@ai-sdk/openai-compatible",
|
|
"name": "llama.cpp",
|
|
"options": options,
|
|
"models": {
|
|
model: {
|
|
"name": opencode_model,
|
|
"tool_call": true,
|
|
"reasoning": config.reasoning_enabled(),
|
|
"attachment": config.attachment_enabled()
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
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": application::agent::resolve_opencode_mcp_timeout_ms()
|
|
}
|
|
}),
|
|
);
|
|
if let Some(permission) = domain::opencode_permission_block(eff) {
|
|
root.insert("permission".to_owned(), permission);
|
|
}
|
|
root.insert(
|
|
"disabled_providers".to_owned(),
|
|
json!(["anthropic", "openai", "gemini", "ollama"]),
|
|
);
|
|
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>,
|
|
eff: Option<&EffectivePermissions>,
|
|
) -> 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)),
|
|
);
|
|
let mut options = Map::new();
|
|
options.insert("apiKey".to_owned(), Value::String(api_key.to_owned()));
|
|
let mut provider_entry = Map::new();
|
|
if let Some(custom) = config.custom.as_ref() {
|
|
// Provider outside the OpenCode registry: it needs the AI SDK package
|
|
// (`npm`), the endpoint (`options.baseURL`), and a `models` block —
|
|
// OpenCode has no built-in knowledge of this provider otherwise.
|
|
options.insert("baseURL".to_owned(), Value::String(custom.base_url.clone()));
|
|
provider_entry.insert("npm".to_owned(), Value::String(custom.npm.clone()));
|
|
let model_label = custom
|
|
.display_name
|
|
.clone()
|
|
.unwrap_or_else(|| config.model.clone());
|
|
provider_entry.insert(
|
|
"models".to_owned(),
|
|
json!({ config.model.as_str(): { "name": model_label } }),
|
|
);
|
|
}
|
|
provider_entry.insert("options".to_owned(), Value::Object(options));
|
|
root.insert(
|
|
"provider".to_owned(),
|
|
json!({ config.provider_id.as_str(): provider_entry }),
|
|
);
|
|
|
|
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": application::agent::resolve_opencode_mcp_timeout_ms()
|
|
}
|
|
}),
|
|
);
|
|
if let Some(permission) = domain::opencode_permission_block(eff) {
|
|
root.insert("permission".to_owned(), permission);
|
|
}
|
|
Value::Object(root)
|
|
}
|
|
|
|
fn codex_config_toml(
|
|
existing: Option<&str>,
|
|
mcp_declaration: &str,
|
|
run_dir: &str,
|
|
project_root: &str,
|
|
model: Option<&str>,
|
|
) -> String {
|
|
let mut text = existing.unwrap_or_default().to_owned();
|
|
if let Some(model) = model {
|
|
text = set_top_level_toml_value(&text, "model", model);
|
|
}
|
|
text = replace_toml_table_block(&text, "mcp_servers.idea", mcp_declaration.trim_end());
|
|
text = ensure_codex_project_trust(&text, run_dir);
|
|
text = ensure_codex_project_trust(&text, project_root);
|
|
if !text.ends_with('\n') {
|
|
text.push('\n');
|
|
}
|
|
text
|
|
}
|
|
|
|
fn set_top_level_toml_value(input: &str, key: &str, value: &str) -> String {
|
|
let line = format!("{key} = {}", toml_quoted(value));
|
|
set_top_level_toml_line(input, key, &line)
|
|
}
|
|
|
|
fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String {
|
|
let needle = format!("{key} =");
|
|
let mut out = Vec::new();
|
|
let mut replaced = false;
|
|
for line in input.lines() {
|
|
let trimmed = line.trim_start();
|
|
if !replaced && trimmed.starts_with(&needle) {
|
|
out.push(replacement.to_owned());
|
|
replaced = true;
|
|
} else {
|
|
out.push(line.to_owned());
|
|
}
|
|
}
|
|
if !replaced {
|
|
if !out.is_empty() && !out.first().is_some_and(|line| line.trim().starts_with('[')) {
|
|
out.push(String::new());
|
|
}
|
|
out.insert(0, replacement.to_owned());
|
|
}
|
|
out.join("\n")
|
|
}
|
|
|
|
fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
|
|
let header = format!("[{table}]");
|
|
let mut out = Vec::new();
|
|
let mut skipping = false;
|
|
let mut inserted = false;
|
|
|
|
for line in existing.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed == header {
|
|
if !inserted {
|
|
out.extend(replacement.lines().map(ToOwned::to_owned));
|
|
inserted = true;
|
|
}
|
|
skipping = true;
|
|
continue;
|
|
}
|
|
if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') {
|
|
skipping = false;
|
|
}
|
|
if !skipping {
|
|
out.push(line.to_owned());
|
|
}
|
|
}
|
|
|
|
if !inserted {
|
|
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
|
|
out.push(String::new());
|
|
}
|
|
out.extend(replacement.lines().map(ToOwned::to_owned));
|
|
}
|
|
|
|
out.join("\n")
|
|
}
|
|
|
|
fn ensure_codex_project_trust(existing: &str, path: &str) -> String {
|
|
if path.is_empty() {
|
|
return existing.to_owned();
|
|
}
|
|
let header = format!(r#"[projects.{}]"#, toml_quoted(path));
|
|
if existing.lines().any(|line| line.trim() == header) {
|
|
return existing.to_owned();
|
|
}
|
|
let mut text = existing.trim_end().to_owned();
|
|
if !text.is_empty() {
|
|
text.push_str("\n\n");
|
|
}
|
|
text.push_str(&header);
|
|
text.push_str("\ntrust_level = \"trusted\"\n");
|
|
text
|
|
}
|
|
|
|
fn toml_quoted(value: &str) -> String {
|
|
let mut out = String::with_capacity(value.len() + 2);
|
|
out.push('"');
|
|
for ch in value.chars() {
|
|
match ch {
|
|
'\\' => out.push_str("\\\\"),
|
|
'"' => out.push_str("\\\""),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
fn join(base: &ProjectPath, rel: &str) -> String {
|
|
let base = base.as_str().trim_end_matches(['/', '\\']);
|
|
format!("{base}/{rel}")
|
|
}
|
|
|
|
fn parent_abs(path: &str) -> Option<&str> {
|
|
path.rsplit_once(['/', '\\'])
|
|
.map(|(parent, _)| parent)
|
|
.filter(|parent| !parent.is_empty())
|
|
}
|
|
|
|
fn parent_dir(base: &ProjectPath, rel: &str) -> String {
|
|
let full = join(base, rel);
|
|
match full.rsplit_once(['/', '\\']) {
|
|
Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
|
|
_ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod opencode_provider_config_json_tests {
|
|
use domain::ports::SecretRef;
|
|
use domain::profile::CustomProviderConfig;
|
|
|
|
use super::*;
|
|
|
|
fn known_provider_config() -> OpenCodeProviderConfig {
|
|
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-ref"))
|
|
.unwrap()
|
|
}
|
|
|
|
fn custom_provider_config() -> OpenCodeProviderConfig {
|
|
let custom =
|
|
CustomProviderConfig::new("@ai-sdk/openai-compatible", "https://my-endpoint/v1", None)
|
|
.unwrap();
|
|
OpenCodeProviderConfig::new("my-custom", "my-model", SecretRef::new("secret-ref"))
|
|
.unwrap()
|
|
.with_custom(custom)
|
|
}
|
|
|
|
#[test]
|
|
fn known_provider_emits_only_the_api_key_option() {
|
|
let config = known_provider_config();
|
|
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
|
|
let provider = &body["provider"]["anthropic"];
|
|
assert_eq!(provider["options"]["apiKey"], "sk-live");
|
|
assert!(provider["options"].get("baseURL").is_none());
|
|
assert!(provider.get("npm").is_none());
|
|
assert!(provider.get("models").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn custom_provider_emits_npm_base_url_and_models() {
|
|
let config = custom_provider_config();
|
|
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
|
|
let provider = &body["provider"]["my-custom"];
|
|
assert_eq!(provider["options"]["apiKey"], "sk-live");
|
|
assert_eq!(provider["options"]["baseURL"], "https://my-endpoint/v1");
|
|
assert_eq!(provider["npm"], "@ai-sdk/openai-compatible");
|
|
assert_eq!(provider["models"]["my-model"]["name"], "my-model");
|
|
}
|
|
|
|
#[test]
|
|
fn custom_provider_model_name_prefers_display_name() {
|
|
let custom = CustomProviderConfig::new(
|
|
"@ai-sdk/openai-compatible",
|
|
"https://my-endpoint/v1",
|
|
Some("My Model".to_owned()),
|
|
)
|
|
.unwrap();
|
|
let config = OpenCodeProviderConfig::new("my-custom", "my-model", SecretRef::new("ref"))
|
|
.unwrap()
|
|
.with_custom(custom);
|
|
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
|
|
assert_eq!(
|
|
body["provider"]["my-custom"]["models"]["my-model"]["name"],
|
|
"My Model"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zai_provider_is_rendered_as_a_cache_independent_provider() {
|
|
let custom = CustomProviderConfig::new(
|
|
"@ai-sdk/openai-compatible",
|
|
"https://api.z.ai/api/paas/v4",
|
|
Some("GLM-5.1".to_owned()),
|
|
)
|
|
.unwrap();
|
|
let config = OpenCodeProviderConfig::new("zai", "glm-5.1", SecretRef::new("ref"))
|
|
.unwrap()
|
|
.with_custom(custom);
|
|
let body = opencode_provider_config_json(&config, "secret", "/project", None, None);
|
|
assert_eq!(body["model"], "zai/glm-5.1");
|
|
assert_eq!(body["provider"]["zai"]["npm"], "@ai-sdk/openai-compatible");
|
|
assert_eq!(
|
|
body["provider"]["zai"]["options"]["baseURL"],
|
|
"https://api.z.ai/api/paas/v4"
|
|
);
|
|
assert_eq!(
|
|
body["provider"]["zai"]["models"]["glm-5.1"]["name"],
|
|
"GLM-5.1"
|
|
);
|
|
}
|
|
|
|
// ---- permission projection wiring (ticket #94) -----------------------
|
|
|
|
/// Builds an [`EffectivePermissions`] with the given fallback posture (only
|
|
/// the fallback drives the blanket bash/edit verdicts checked here).
|
|
fn eff(fallback: domain::permission::Posture) -> EffectivePermissions {
|
|
domain::permission::resolve(
|
|
Some(&domain::permission::PermissionSet::new(vec![], fallback)),
|
|
None,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_config_json_omits_permission_key_when_eff_is_none() {
|
|
let config =
|
|
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
|
|
.unwrap();
|
|
let body = opencode_config_json(&config, "/project", None, None);
|
|
assert!(body.get("permission").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_config_json_projects_allow_and_deny() {
|
|
let config =
|
|
domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None)
|
|
.unwrap();
|
|
|
|
let allowed = opencode_config_json(
|
|
&config,
|
|
"/project",
|
|
None,
|
|
Some(&eff(domain::permission::Posture::Allow)),
|
|
);
|
|
assert_eq!(allowed["permission"]["bash"], "allow");
|
|
assert_eq!(allowed["permission"]["edit"], "allow");
|
|
|
|
let denied = opencode_config_json(
|
|
&config,
|
|
"/project",
|
|
None,
|
|
Some(&eff(domain::permission::Posture::Deny)),
|
|
);
|
|
assert_eq!(denied["permission"]["bash"], "deny");
|
|
assert_eq!(denied["permission"]["edit"], "deny");
|
|
}
|
|
|
|
#[test]
|
|
fn provider_config_json_omits_permission_key_when_eff_is_none() {
|
|
let config = known_provider_config();
|
|
let body = opencode_provider_config_json(&config, "sk-live", "/project", None, None);
|
|
assert!(body.get("permission").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn provider_config_json_projects_allow_and_deny() {
|
|
let config = known_provider_config();
|
|
|
|
let allowed = opencode_provider_config_json(
|
|
&config,
|
|
"sk-live",
|
|
"/project",
|
|
None,
|
|
Some(&eff(domain::permission::Posture::Allow)),
|
|
);
|
|
assert_eq!(allowed["permission"]["bash"], "allow");
|
|
assert_eq!(allowed["permission"]["edit"], "allow");
|
|
|
|
let denied = opencode_provider_config_json(
|
|
&config,
|
|
"sk-live",
|
|
"/project",
|
|
None,
|
|
Some(&eff(domain::permission::Posture::Deny)),
|
|
);
|
|
assert_eq!(denied["permission"]["bash"], "deny");
|
|
assert_eq!(denied["permission"]["edit"], "deny");
|
|
}
|
|
}
|