Files
IdeA/crates/infrastructure/src/assistant/mod.rs
Blomios 0821739924 feat: finalise ticket99 - implémentation agent model configuration v2
- 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
2026-07-26 13:38:18 +02:00

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");
}
}