333 lines
11 KiB
Rust

use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent};
use crate::cli_agent_runtime::{AgentStreamRequest, LineStreamProcess};
use std::path::Path;
use std::process::{Command, Stdio};
struct CopilotCommandSpec {
prompt: String,
mcp_config: String,
vault_path: String,
permission_mode: AiAgentPermissionMode,
}
struct CopilotMcpConfigInput<'a> {
request: &'a AgentStreamRequest,
mcp_server_path: &'a str,
node_command: &'a str,
}
pub fn check_cli() -> AiAgentAvailability {
crate::copilot_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::copilot_discovery::find_binary()?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let mcp_config = build_copilot_mcp_config(&request)?;
let spec = command_spec(request, mcp_config);
run_agent_stream_with_spec(binary, spec, emit)
}
fn command_spec(request: AgentStreamRequest, mcp_config: String) -> CopilotCommandSpec {
let prompt =
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref());
CopilotCommandSpec {
prompt,
mcp_config,
vault_path: request.vault_path,
permission_mode: request.permission_mode,
}
}
fn run_agent_stream_with_spec<F>(
binary: &Path,
spec: CopilotCommandSpec,
emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let command = build_copilot_command(binary, spec)?;
crate::cli_agent_runtime::run_ai_agent_line_stream(
LineStreamProcess::new(command, "copilot", "copilot"),
emit,
format_copilot_error,
)
}
fn build_copilot_command(binary: &Path, spec: CopilotCommandSpec) -> Result<Command, String> {
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
let mut command = crate::hidden_command(&target.program);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
if let Some(first_arg) = target.first_arg {
command.arg(first_arg);
}
command
.args(build_copilot_args(&spec))
.current_dir(spec.vault_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
fn build_copilot_args(spec: &CopilotCommandSpec) -> Vec<String> {
let mut args = vec![
"-p".into(),
spec.prompt.clone(),
"-s".into(),
"--no-ask-user".into(),
"--additional-mcp-config".into(),
spec.mcp_config.clone(),
];
append_permission_args(&mut args, spec.permission_mode);
args
}
fn append_permission_args(args: &mut Vec<String>, permission_mode: AiAgentPermissionMode) {
match permission_mode {
AiAgentPermissionMode::Safe => {
args.push("--available-tools=write,tolaria".into());
args.push("--allow-tool=write,tolaria".into());
args.push("--deny-tool=shell".into());
}
AiAgentPermissionMode::PowerUser => args.push("--allow-all-tools".into()),
}
}
fn build_copilot_mcp_config(request: &AgentStreamRequest) -> Result<String, String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
let node_path = crate::mcp::find_node()?;
let node_command = node_path.to_string_lossy();
copilot_mcp_config_json(CopilotMcpConfigInput {
request,
mcp_server_path: &mcp_server_path,
node_command: node_command.as_ref(),
})
}
fn copilot_mcp_config_json(input: CopilotMcpConfigInput<'_>) -> Result<String, String> {
let mut server = crate::cli_agent_runtime::tolaria_node_mcp_server(
input.mcp_server_path,
&input.request.vault_path,
&input.request.vault_paths,
true,
);
server["type"] = serde_json::json!("stdio");
server["tools"] = serde_json::json!(["*"]);
server["command"] = serde_json::json!(input.node_command);
let config = serde_json::json!({
"mcpServers": {
"tolaria": server
}
});
serde_json::to_string(&config)
.map_err(|error| format!("Failed to serialise MCP config: {error}"))
}
fn format_copilot_error(stderr_output: &str, status: &str) -> String {
if is_auth_or_setup_error(stderr_output) {
return "GitHub Copilot CLI is not ready. Run `copilot login` in your terminal, then run `copilot` from this vault folder and trust it before retrying in Tolaria.".into();
}
let stderr = stderr_output.trim();
if stderr.is_empty() {
format!("copilot exited with status {status}")
} else {
stderr.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_auth_or_setup_error(stderr_output: &str) -> bool {
let lower = stderr_output.to_ascii_lowercase();
[
"auth",
"login",
"oauth",
"policy",
"sign in",
"subscription",
"token",
"trust",
"unauthorized",
]
.iter()
.any(|needle| lower.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn spec(permission_mode: AiAgentPermissionMode) -> CopilotCommandSpec {
CopilotCommandSpec {
prompt: "Prompt".into(),
mcp_config: r#"{"mcpServers":{"tolaria":{}}}"#.into(),
vault_path: "/tmp/vault".into(),
permission_mode,
}
}
fn request(vault_path: String, permission_mode: AiAgentPermissionMode) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: Some("Use Tolaria conventions".into()),
vault_path,
vault_paths: vec!["/team-vault".into()],
permission_mode,
}
}
#[cfg(unix)]
fn executable_script(dir: &Path, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("copilot");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
#[test]
fn build_copilot_command_uses_programmatic_prompt_and_safe_tools() {
let dir = tempfile::tempdir().unwrap();
let binary = dir.path().join("copilot");
let command = build_copilot_command(&binary, spec(AiAgentPermissionMode::Safe)).unwrap();
let actual_args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
assert_eq!(command.get_program(), binary.as_os_str());
assert!(actual_args
.windows(2)
.any(|window| window == ["-p", "Prompt"]));
assert!(actual_args.contains(&"-s".to_string()));
assert!(actual_args.contains(&"--no-ask-user".to_string()));
assert!(actual_args.contains(&"--available-tools=write,tolaria".to_string()));
assert!(actual_args.contains(&"--allow-tool=write,tolaria".to_string()));
assert!(actual_args.contains(&"--deny-tool=shell".to_string()));
assert!(!actual_args
.iter()
.any(|arg| arg == "--allow-all" || arg == "--yolo"));
}
#[test]
fn build_copilot_args_uses_power_user_without_path_bypass() {
let args = build_copilot_args(&spec(AiAgentPermissionMode::PowerUser));
assert!(args.contains(&"--allow-all-tools".to_string()));
assert!(!args.iter().any(|arg| {
matches!(
arg.as_str(),
"--allow-all" | "--yolo" | "--allow-all-paths" | "--allow-all-urls"
)
}));
}
#[test]
fn copilot_mcp_config_uses_tolaria_stdio_server() {
let request = request("/tmp/vault".into(), AiAgentPermissionMode::Safe);
let config = copilot_mcp_config_json(CopilotMcpConfigInput {
request: &request,
mcp_server_path: "/opt/tolaria/mcp-server/index.js",
node_command: "/usr/local/bin/node",
})
.unwrap();
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
let server = &json["mcpServers"]["tolaria"];
assert_eq!(server["type"], "stdio");
assert_eq!(server["command"], "/usr/local/bin/node");
assert_eq!(server["args"][0], "/opt/tolaria/mcp-server/index.js");
assert_eq!(server["tools"][0], "*");
assert_eq!(server["env"]["VAULT_PATH"], "/tmp/vault");
assert_eq!(server["env"]["WS_UI_PORT"], "9711");
}
#[cfg(unix)]
#[test]
fn run_agent_stream_maps_copilot_stdout() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' 'Hello from Copilot'
printf '%s\n' 'Second line'
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_spec(
&binary,
CopilotCommandSpec {
vault_path: vault.path().to_string_lossy().into_owned(),
..spec(AiAgentPermissionMode::Safe)
},
|event| events.push(event),
)
.unwrap();
assert!(session_id.starts_with("copilot-"));
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id.starts_with("copilot-")
));
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::TextDelta { text } if text == "Hello from Copilot\n"
)));
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::TextDelta { text } if text == "Second line\n"
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_agent_stream_reports_copilot_auth_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' 'login required' >&2
exit 2
"#,
);
let mut events = Vec::new();
run_agent_stream_with_spec(
&binary,
CopilotCommandSpec {
vault_path: vault.path().to_string_lossy().into_owned(),
..spec(AiAgentPermissionMode::Safe)
},
|event| events.push(event),
)
.unwrap();
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("copilot login")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
}