2026-04-29 00:52:27 +02:00
|
|
|
use crate::ai_agents::AiAgentPermissionMode;
|
2026-04-28 01:26:21 +02:00
|
|
|
use crate::opencode_cli::AgentStreamRequest;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::process::Stdio;
|
|
|
|
|
|
|
|
|
|
pub(crate) fn build_command(
|
|
|
|
|
binary: &Path,
|
|
|
|
|
request: &AgentStreamRequest,
|
|
|
|
|
) -> Result<std::process::Command, String> {
|
2026-05-10 11:18:56 +02:00
|
|
|
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
|
|
|
|
|
let mut command = crate::hidden_command(&target.program);
|
2026-05-02 01:08:09 +02:00
|
|
|
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
2026-05-10 11:18:56 +02:00
|
|
|
if let Some(first_arg) = target.first_arg {
|
|
|
|
|
command.arg(first_arg);
|
|
|
|
|
}
|
2026-04-28 01:26:21 +02:00
|
|
|
command
|
|
|
|
|
.args(build_args())
|
|
|
|
|
.arg(build_prompt(request))
|
|
|
|
|
.env(
|
|
|
|
|
"OPENCODE_CONFIG_CONTENT",
|
2026-05-11 16:37:06 +02:00
|
|
|
build_config(
|
|
|
|
|
&request.vault_path,
|
|
|
|
|
&request.vault_paths,
|
|
|
|
|
request.permission_mode,
|
|
|
|
|
)?,
|
2026-04-28 01:26:21 +02:00
|
|
|
)
|
|
|
|
|
.current_dir(&request.vault_path)
|
|
|
|
|
.stdin(Stdio::null())
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped());
|
|
|
|
|
Ok(command)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_args() -> Vec<String> {
|
|
|
|
|
vec!["run".into(), "--format".into(), "json".into()]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_prompt(request: &AgentStreamRequest) -> String {
|
2026-04-29 04:47:16 +02:00
|
|
|
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
2026-04-28 01:26:21 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 00:52:27 +02:00
|
|
|
fn build_config(
|
|
|
|
|
vault_path: &str,
|
2026-05-11 16:37:06 +02:00
|
|
|
vault_paths: &[String],
|
2026-04-29 00:52:27 +02:00
|
|
|
permission_mode: AiAgentPermissionMode,
|
|
|
|
|
) -> Result<String, String> {
|
2026-04-29 04:47:16 +02:00
|
|
|
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
2026-05-11 16:37:06 +02:00
|
|
|
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths);
|
2026-04-28 01:26:21 +02:00
|
|
|
|
|
|
|
|
serde_json::to_string(&serde_json::json!({
|
|
|
|
|
"$schema": "https://opencode.ai/config.json",
|
2026-04-29 00:52:27 +02:00
|
|
|
"permission": permission_config(permission_mode),
|
2026-04-28 01:26:21 +02:00
|
|
|
"mcp": {
|
|
|
|
|
"tolaria": {
|
|
|
|
|
"type": "local",
|
|
|
|
|
"command": ["node", mcp_server_path],
|
2026-05-11 16:37:06 +02:00
|
|
|
"environment": {
|
|
|
|
|
"VAULT_PATH": vault_path,
|
|
|
|
|
"VAULT_PATHS": vault_paths
|
|
|
|
|
},
|
2026-04-28 01:26:21 +02:00
|
|
|
"enabled": true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 00:52:27 +02:00
|
|
|
fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value {
|
|
|
|
|
let bash_permission = match permission_mode {
|
|
|
|
|
AiAgentPermissionMode::Safe => "deny",
|
|
|
|
|
AiAgentPermissionMode::PowerUser => "allow",
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-28 01:26:21 +02:00
|
|
|
serde_json::json!({
|
|
|
|
|
"read": "allow",
|
|
|
|
|
"edit": "allow",
|
|
|
|
|
"glob": "allow",
|
|
|
|
|
"grep": "allow",
|
|
|
|
|
"list": "allow",
|
|
|
|
|
"external_directory": "deny",
|
2026-04-29 00:52:27 +02:00
|
|
|
"bash": bash_permission
|
2026-04-28 01:26:21 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use std::ffi::OsStr;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
fn request() -> AgentStreamRequest {
|
|
|
|
|
AgentStreamRequest {
|
|
|
|
|
message: "Rename the note".into(),
|
|
|
|
|
system_prompt: None,
|
|
|
|
|
vault_path: "/tmp/vault".into(),
|
2026-05-11 16:37:06 +02:00
|
|
|
vault_paths: Vec::new(),
|
2026-04-29 00:52:27 +02:00
|
|
|
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
|
2026-04-28 01:26:21 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn args_use_documented_safe_run_mode() {
|
|
|
|
|
let args = build_args();
|
2026-05-10 11:18:56 +02:00
|
|
|
let forbidden_args = (
|
|
|
|
|
args.contains(&"--dangerously-skip-permissions".to_string()),
|
|
|
|
|
args.contains(&"--dir".to_string()),
|
|
|
|
|
args.contains(&"--thinking".to_string()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
(args, forbidden_args),
|
|
|
|
|
(
|
|
|
|
|
vec![
|
|
|
|
|
"run".to_string(),
|
|
|
|
|
"--format".to_string(),
|
|
|
|
|
"json".to_string()
|
|
|
|
|
],
|
|
|
|
|
(false, false, false),
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-04-28 01:26:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn command_sets_vault_cwd_and_mcp_config() {
|
|
|
|
|
let command = build_command(&PathBuf::from("opencode"), &request()).unwrap();
|
|
|
|
|
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
|
|
|
|
let config_value = command
|
|
|
|
|
.get_envs()
|
|
|
|
|
.find(|(key, _)| *key == OsStr::new("OPENCODE_CONFIG_CONTENT"))
|
|
|
|
|
.and_then(|(_, value)| value);
|
|
|
|
|
|
2026-05-10 11:18:56 +02:00
|
|
|
assert_eq!(
|
|
|
|
|
(
|
|
|
|
|
command.get_program(),
|
|
|
|
|
actual_args[0],
|
|
|
|
|
actual_args[1],
|
|
|
|
|
actual_args[2],
|
|
|
|
|
actual_args.last().copied(),
|
|
|
|
|
command.get_current_dir(),
|
|
|
|
|
config_value.is_some(),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
OsStr::new("opencode"),
|
|
|
|
|
OsStr::new("run"),
|
|
|
|
|
OsStr::new("--format"),
|
|
|
|
|
OsStr::new("json"),
|
|
|
|
|
Some(OsStr::new("Rename the note")),
|
|
|
|
|
Some(Path::new("/tmp/vault")),
|
|
|
|
|
true,
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn command_avoids_windows_cmd_shim_for_run_args() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let shim = dir.path().join("opencode.cmd");
|
|
|
|
|
let script = dir
|
|
|
|
|
.path()
|
|
|
|
|
.join("node_modules")
|
|
|
|
|
.join("opencode")
|
|
|
|
|
.join("bin")
|
|
|
|
|
.join("opencode.js");
|
|
|
|
|
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
|
|
|
|
|
std::fs::write(&script, "console.log('opencode')\n").unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&shim,
|
|
|
|
|
r#"@ECHO off
|
|
|
|
|
"%_prog%" "%~dp0\node_modules\opencode\bin\opencode.js" %*
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let command = build_command(&shim, &request()).unwrap();
|
|
|
|
|
let actual_args = command.get_args().collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
assert_ne!(
|
|
|
|
|
command.get_program(),
|
|
|
|
|
shim.as_os_str(),
|
|
|
|
|
"OpenCode npm .cmd shims cannot be spawned directly on Windows"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
(
|
|
|
|
|
actual_args.first().copied(),
|
|
|
|
|
actual_args.iter().any(|arg| *arg == OsStr::new("run")),
|
|
|
|
|
actual_args.iter().any(|arg| *arg == OsStr::new("json")),
|
|
|
|
|
actual_args
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|arg| *arg == OsStr::new("Rename the note")),
|
|
|
|
|
),
|
|
|
|
|
(Some(script.as_os_str()), true, true, true)
|
|
|
|
|
);
|
2026-04-28 01:26:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn config_includes_permissions_and_tolaria_mcp_server() {
|
2026-05-11 16:37:06 +02:00
|
|
|
if let Ok(config) = build_config(
|
|
|
|
|
"/tmp/vault",
|
|
|
|
|
&[],
|
|
|
|
|
crate::ai_agents::AiAgentPermissionMode::Safe,
|
|
|
|
|
) {
|
2026-04-28 01:26:21 +02:00
|
|
|
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
|
|
|
|
assert_eq!(
|
2026-05-10 11:18:56 +02:00
|
|
|
(
|
|
|
|
|
json["permission"]["edit"].as_str(),
|
|
|
|
|
json["permission"]["external_directory"].as_str(),
|
|
|
|
|
json["permission"]["bash"].as_str(),
|
|
|
|
|
json["mcp"]["tolaria"]["type"].as_str(),
|
|
|
|
|
json["mcp"]["tolaria"]["command"][0].as_str(),
|
|
|
|
|
json["mcp"]["tolaria"]["environment"]["VAULT_PATH"].as_str(),
|
|
|
|
|
json["mcp"]["tolaria"]["command"][1]
|
|
|
|
|
.as_str()
|
|
|
|
|
.is_some_and(|path| path.ends_with("index.js")),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
Some("allow"),
|
|
|
|
|
Some("deny"),
|
|
|
|
|
Some("deny"),
|
|
|
|
|
Some("local"),
|
|
|
|
|
Some("node"),
|
|
|
|
|
Some("/tmp/vault"),
|
|
|
|
|
true,
|
|
|
|
|
)
|
2026-04-28 01:26:21 +02:00
|
|
|
);
|
2026-05-11 16:37:06 +02:00
|
|
|
assert_eq!(
|
|
|
|
|
json["mcp"]["tolaria"]["environment"]["VAULT_PATHS"],
|
|
|
|
|
r#"["/tmp/vault"]"#
|
|
|
|
|
);
|
|
|
|
|
assert!(json["mcp"]["tolaria"]["command"][1]
|
|
|
|
|
.as_str()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.ends_with("index.js"));
|
2026-04-28 01:26:21 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 00:52:27 +02:00
|
|
|
#[test]
|
|
|
|
|
fn power_user_config_allows_bash_but_keeps_external_directories_denied() {
|
|
|
|
|
if let Ok(config) = build_config(
|
|
|
|
|
"/tmp/vault",
|
2026-05-11 16:37:06 +02:00
|
|
|
&[],
|
2026-04-29 00:52:27 +02:00
|
|
|
crate::ai_agents::AiAgentPermissionMode::PowerUser,
|
|
|
|
|
) {
|
|
|
|
|
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
|
|
|
|
assert_eq!(json["permission"]["bash"], "allow");
|
|
|
|
|
assert_eq!(json["permission"]["external_directory"], "deny");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 01:26:21 +02:00
|
|
|
#[test]
|
|
|
|
|
fn prompt_keeps_system_prompt_first() {
|
|
|
|
|
let prompt = build_prompt(&AgentStreamRequest {
|
|
|
|
|
system_prompt: Some("Be concise".into()),
|
|
|
|
|
..request()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
|
|
|
|
assert!(prompt.contains("User request:\nRename the note"));
|
|
|
|
|
}
|
|
|
|
|
}
|