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(request: AgentStreamRequest, emit: F) -> Result 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( binary: &Path, request: AgentStreamRequest, emit: F, ) -> Result 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( binary: &Path, spec: CopilotCommandSpec, emit: F, ) -> Result 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 { 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 { 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, 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 { 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 { 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::>().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::>(); 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))); } }