333 lines
11 KiB
Rust
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)));
|
|
}
|
|
}
|