diff --git a/AGENTS.md b/AGENTS.md index b2f14790..c19b9fce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1017,14 +1017,6 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `--ado-project ` - Azure DevOps project name override - `--dry-run` - Validate inputs but skip ADO API calls (useful for local testing and QA review) -- `run ` - Run an agent locally (local development mode). Orchestrates the full agent lifecycle: starts SafeOutputs, optionally starts MCPG via Docker, generates configs, runs copilot, and executes safe outputs. - - `--pat ` / `AZURE_DEVOPS_EXT_PAT` env var - Azure DevOps PAT for API access (passed to MCPG for ADO MCP, copilot env, and Stage 3 execution) - - `--org ` - Azure DevOps organization URL (overrides auto-inference from git remote) - - `--project ` - Azure DevOps project name - - `--dry-run` - Skip ADO API calls in the execute stage - - `--skip-mcpg` - Skip MCPG/Docker (only SafeOutputs MCP available; auto-enabled when Docker is unavailable) - - `--output-dir ` - Output directory for safe outputs and artifacts (defaults to a temp directory) - - `configure` - Detect agentic pipelines in a local repository and update the `GITHUB_TOKEN` pipeline variable on their Azure DevOps build definitions - `--token ` / `GITHUB_TOKEN` env var - The new GITHUB_TOKEN value (prompted if omitted) - `--org ` - Override: Azure DevOps organization URL (inferred from git remote by default) diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 00000000..23371687 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,202 @@ + + +# Local Development Guide + +This guide explains how to run an agentic pipeline locally for development and +testing. The workflow mirrors what the compiled Azure DevOps pipeline does, but +each step is run manually on your machine. + +## Prerequisites + +- **ado-aw** built from source (`cargo build`) +- **Copilot CLI** on your PATH (`copilot --version`) +- **Docker** (optional, required for MCPG / custom MCP servers) +- An Azure DevOps PAT if your agent uses ADO APIs + +## Overview + +A pipeline execution has three stages: + +1. **SafeOutputs MCP server** — receives tool calls from the agent and writes + them as NDJSON records +2. **Agent execution** — Copilot CLI runs with a prompt and MCP config, + interacting with SafeOutputs (and optionally other MCPs via MCPG) +3. **Safe output execution** — processes the NDJSON records and makes real ADO + API calls (create PRs, work items, etc.) + +## Step-by-step + +### 1. Create a working directory + +```bash +export WORK_DIR=$(mktemp -d) +echo "Working directory: $WORK_DIR" +``` + +### 2. Start the SafeOutputs HTTP server + +```bash +# Pick a port and generate an API key +export SO_PORT=8100 +export SO_API_KEY=$(openssl rand -hex 32) + +# Start in the background +cargo run -- mcp-http \ + --port "$SO_PORT" \ + --api-key "$SO_API_KEY" \ + "$WORK_DIR" \ + "$(pwd)" \ + > "$WORK_DIR/safeoutputs.log" 2>&1 & +export SO_PID=$! +echo "SafeOutputs PID: $SO_PID" + +# Wait for health check +until curl -sf "http://127.0.0.1:$SO_PORT/health" > /dev/null 2>&1; do + sleep 1 +done +echo "SafeOutputs ready" +``` + +### 3. (Optional) Start MCPG for custom MCP servers + +Skip this step if your agent only uses SafeOutputs (no `mcp-servers:` or +`tools: azure-devops:` in front matter). + +```bash +export MCPG_PORT=8080 +export MCPG_API_KEY=$(openssl rand -hex 32) + +# Generate MCPG config — adapt the JSON to your agent's mcp-servers front matter. +# See the compiled pipeline's mcpg-config.json for the expected format. +cat > "$WORK_DIR/mcpg-config.json" < "$WORK_DIR/gateway-output.json" 2>"$WORK_DIR/mcpg-stderr.log" & +export MCPG_PID=$! + +# Wait for MCPG health check +until curl -sf "http://127.0.0.1:$MCPG_PORT/health" > /dev/null 2>&1; do + sleep 1 +done +echo "MCPG ready" +``` + +### 4. Generate the MCP client config for Copilot + +**Without MCPG** (SafeOutputs only): + +```bash +cat > "$WORK_DIR/mcp-config.json" <=2' "$AGENT_FILE" > "$WORK_DIR/agent-prompt.md" +``` + +### 6. Run the Copilot CLI + +```bash +copilot \ + --prompt "@$WORK_DIR/agent-prompt.md" \ + --additional-mcp-config "@$WORK_DIR/mcp-config.json" \ + --model claude-opus-4.5 \ + --no-ask-user \ + --disable-builtin-mcps \ + --allow-all-tools +``` + +Adjust flags based on your agent's front matter (model, allowed tools, etc.). + +### 7. Execute safe outputs + +```bash +cargo run -- execute \ + --source "$AGENT_FILE" \ + --safe-output-dir "$WORK_DIR" \ + --dry-run # Remove --dry-run to make real ADO API calls +``` + +### 8. Cleanup + +```bash +# Stop SafeOutputs +kill "$SO_PID" 2>/dev/null + +# Stop MCPG (if started) +docker stop ado-aw-mcpg 2>/dev/null + +echo "Done. Output files in: $WORK_DIR" +``` + +## Tips + +- Use `--dry-run` on the execute step to validate safe outputs without making + real ADO API calls +- Set `AZURE_DEVOPS_EXT_PAT` for agents that need ADO API access +- Check `$WORK_DIR/safeoutputs.log` and `$WORK_DIR/mcpg-stderr.log` for + debugging +- The compiled pipeline YAML shows the exact flags and config used in CI — use + `ado-aw compile your-agent.md` and inspect the output for reference diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 6a04796d..f83d3d5f 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -366,7 +366,7 @@ mod github; mod safe_outputs; // Re-export tool/runtime extensions from their colocated homes -pub use crate::tools::azure_devops::{AdoAuthMode, AzureDevOpsExtension}; +pub use crate::tools::azure_devops::AzureDevOpsExtension; pub use crate::tools::cache_memory::CacheMemoryExtension; pub use github::GitHubExtension; pub use crate::runtimes::lean::LeanExtension; @@ -402,16 +402,6 @@ extension_enum! { /// (runtimes in `RuntimesConfig` field order, tools in `ToolsConfig` /// field order). pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { - collect_extensions_with_auth(front_matter, AdoAuthMode::default()) -} - -/// Collect extensions with an explicit ADO auth mode. -/// -/// Used by `ado-aw run` to switch from bearer (pipeline default) to PAT auth. -pub fn collect_extensions_with_auth( - front_matter: &FrontMatter, - ado_auth: AdoAuthMode, -) -> Vec { let mut extensions = Vec::new(); // ── Always-on internal extensions ── @@ -430,7 +420,7 @@ pub fn collect_extensions_with_auth( if let Some(ado) = tools.azure_devops.as_ref() { if ado.is_enabled() { extensions.push(Extension::AzureDevOps( - AzureDevOpsExtension::new(ado.clone()).with_auth_mode(ado_auth), + AzureDevOpsExtension::new(ado.clone()), )); } } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 3948e4b0..f9dcd9ae 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -19,9 +19,6 @@ use std::path::{Path, PathBuf}; pub use common::parse_markdown; pub use common::HEADER_MARKER; -pub use common::generate_mcpg_config; -pub use common::MCPG_IMAGE; -pub use common::MCPG_VERSION; pub use common::ADO_MCP_ENTRYPOINT; pub use common::ADO_MCP_IMAGE; pub use common::ADO_MCP_PACKAGE; diff --git a/src/compile/types.rs b/src/compile/types.rs index 58611992..804cd7d2 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -457,23 +457,6 @@ impl AzureDevOpsToolConfig { AzureDevOpsToolConfig::WithOptions(opts) => opts.org.as_deref(), } } - - /// Set the org override (for local run mode when --org is provided). - /// Converts `Enabled(true)` to `WithOptions` with the org set. - pub fn set_org(&mut self, org: String) { - match self { - AzureDevOpsToolConfig::Enabled(true) => { - *self = AzureDevOpsToolConfig::WithOptions(AzureDevOpsOptions { - org: Some(org), - ..Default::default() - }); - } - AzureDevOpsToolConfig::WithOptions(opts) => { - opts.org = Some(org); - } - AzureDevOpsToolConfig::Enabled(false) => {} - } - } } impl SanitizeConfigTrait for AzureDevOpsToolConfig { diff --git a/src/main.rs b/src/main.rs index e52ac1ec..195d20f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,8 +12,6 @@ mod logging; mod mcp; mod ndjson; pub mod runtimes; -#[cfg(debug_assertions)] -mod run; pub mod sanitize; mod safeoutputs; mod tools; @@ -131,30 +129,6 @@ enum Commands { #[arg(long, value_delimiter = ',')] definition_ids: Option>, }, - /// Run agent locally (local development mode) - #[cfg(debug_assertions)] - Run { - /// Path to the agent markdown file - path: String, - /// Azure DevOps PAT for API access (base64-encoded as PERSONAL_ACCESS_TOKEN for MCPG in local dev) - #[arg(long, env = "AZURE_DEVOPS_EXT_PAT")] - pat: Option, - /// Azure DevOps organization URL - #[arg(long)] - org: Option, - /// Azure DevOps project name - #[arg(long)] - project: Option, - /// Dry-run: skip real ADO API calls in execute stage - #[arg(long)] - dry_run: bool, - /// Skip MCPG/Docker (only SafeOutputs MCP available) - #[arg(long)] - skip_mcpg: bool, - /// Output directory for safe outputs and artifacts - #[arg(long)] - output_dir: Option, - }, } #[derive(Parser, Debug)] @@ -183,8 +157,6 @@ async fn main() -> Result<()> { Some(Commands::McpHttp { .. }) => "mcp-http", Some(Commands::Init { .. }) => "init", Some(Commands::Configure { .. }) => "configure", - #[cfg(debug_assertions)] - Some(Commands::Run { .. }) => "run", None => "ado-aw", }; @@ -391,28 +363,6 @@ async fn main() -> Result<()> { ) .await?; } - #[cfg(debug_assertions)] - Commands::Run { - path, - pat, - org, - project, - dry_run, - skip_mcpg, - output_dir, - } => { - run::run(&run::RunArgs { - agent_path: PathBuf::from(path), - pat, - org, - project, - dry_run, - skip_mcpg, - output_dir, - debug: args.debug, - }) - .await?; - } } } else { println!("No subcommand was used. Try `compile `"); diff --git a/src/run.rs b/src/run.rs deleted file mode 100644 index 14f862a1..00000000 --- a/src/run.rs +++ /dev/null @@ -1,1204 +0,0 @@ -//! Local development run mode. -//! -//! Orchestrates the full agent lifecycle locally: parse markdown → -//! start SafeOutputs → optionally start MCPG → generate configs → -//! exec copilot → execute safe outputs → cleanup. - -use anyhow::{Context, Result, bail}; -use log::{debug, info, warn}; -use std::collections::HashMap; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; - -use crate::compile; -use crate::sanitize::SanitizeConfig; - -/// Arguments for the `run` subcommand. -pub struct RunArgs { - pub agent_path: PathBuf, - pub pat: Option, - pub org: Option, - pub project: Option, - pub dry_run: bool, - pub skip_mcpg: bool, - pub output_dir: Option, - pub debug: bool, -} - -/// Guard that kills child processes on drop (normal exit, error, or panic). -struct CleanupGuard { - safeoutputs_child: Option, - mcpg_child: Option, - /// Keeps the env file alive until MCPG exits. The file must outlive - /// `spawn()` because spawn is just fork — the Docker CLI reads - /// `--env-file` after exec, not before spawn returns. - #[allow(dead_code)] - mcpg_env_file: Option, -} - -impl Drop for CleanupGuard { - fn drop(&mut self) { - if let Some(ref mut child) = self.safeoutputs_child { - info!("Stopping SafeOutputs server..."); - let _ = child.kill(); - let _ = child.wait(); - } - if self.mcpg_child.is_some() { - info!("Stopping MCPG container..."); - let _ = Command::new("docker") - .args(["stop", "ado-aw-mcpg"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - // Reap the docker run process to prevent zombie - if let Some(ref mut child) = self.mcpg_child { - let _ = child.wait(); - } - } - } -} - -/// Strip the `\\?\` extended-length path prefix that Windows `canonicalize()` -/// adds. This prefix breaks many tools (including Copilot CLI) that don't -/// understand UNC paths. Only strips when the path is a regular drive path -/// (e.g., `\\?\C:\...` → `C:\...`), not a true UNC path (`\\?\UNC\...`). -fn strip_unc_prefix(path: PathBuf) -> PathBuf { - let s = path.to_string_lossy(); - if let Some(rest) = s.strip_prefix(r"\\?\") { - if !rest.starts_with(r"UNC\") { - return PathBuf::from(rest); - } - } - path -} - -/// Find a free TCP port by binding to port 0. -/// -/// Note: There is an inherent TOCTOU race — the port is released before the -/// child process binds it. Another process could grab it in the gap. This is -/// acceptable for a local dev tool; fixing it would require refactoring the -/// mcp-http server to accept a pre-bound listener. -fn find_free_port() -> Result { - let listener = std::net::TcpListener::bind("127.0.0.1:0") - .context("Failed to bind to a free port")?; - let port = listener.local_addr()?.port(); - drop(listener); - Ok(port) -} - -/// Generate a random alphanumeric API key (at least 40 chars). -fn generate_api_key() -> String { - use rand::RngExt; - let mut bytes = [0u8; 48]; - rand::rng().fill(&mut bytes); - base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &bytes) - .chars() - .filter(|c| c.is_ascii_alphanumeric()) - .collect() -} - -/// Start the SafeOutputs MCP HTTP server as a child process. -async fn start_safeoutputs( - output_dir: &Path, - bounding_dir: &Path, - enabled_tools: &[String], -) -> Result<(Child, u16, String)> { - let port = find_free_port()?; - let api_key = generate_api_key(); - - let exe = std::env::current_exe().context("Failed to determine current executable path")?; - - let mut cmd = Command::new(&exe); - cmd.arg("mcp-http") - .arg("--port") - .arg(port.to_string()) - .arg("--api-key") - .arg(&api_key); - - for tool in enabled_tools { - cmd.arg("--enabled-tools").arg(tool); - } - - cmd.arg(output_dir.to_string_lossy().as_ref()) - .arg(bounding_dir.to_string_lossy().as_ref()); - - // Redirect output to log files - let log_dir = output_dir.join("logs"); - std::fs::create_dir_all(&log_dir)?; - let stdout_log = log_dir.join("safeoutputs.stdout.log"); - let stderr_log = log_dir.join("safeoutputs.stderr.log"); - let stdout_file = std::fs::File::create(&stdout_log) - .with_context(|| format!("Failed to create log file: {}", stdout_log.display()))?; - let stderr_file = std::fs::File::create(&stderr_log) - .with_context(|| format!("Failed to create log file: {}", stderr_log.display()))?; - - cmd.stdout(stdout_file).stderr(stderr_file); - - let mut child = cmd.spawn().context("Failed to start SafeOutputs HTTP server")?; - info!("SafeOutputs started (PID: {}, port: {})", child.id(), port); - - // Health check — also detect early crash via try_wait() - let client = reqwest::Client::new(); - let health_url = format!("http://127.0.0.1:{}/health", port); - let mut ready = false; - for _ in 0..30 { - // Check if process crashed before polling the endpoint - if let Some(status) = child.try_wait()? { - bail!( - "SafeOutputs HTTP server exited during startup with {}. \ - Check logs at {}", - status, - log_dir.display() - ); - } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - match client.get(&health_url).send().await { - Ok(resp) if resp.status().is_success() => { - ready = true; - break; - } - _ => continue, - } - } - if !ready { - bail!("SafeOutputs HTTP server did not become ready within 30s on port {}", port); - } - - Ok((child, port, api_key)) -} - -/// Check if Docker is available. -fn is_docker_available() -> bool { - Command::new("docker") - .arg("info") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -/// Probe all MCP backends via MCPG to force eager launch and surface failures. -/// -/// Sends MCP `initialize` + `tools/list` handshakes to each server listed in -/// the MCP client config. Results are printed to stdout — failures are warnings, -/// not hard errors, since some backends may intentionally be unavailable. -async fn probe_mcp_backends( - client: &reqwest::Client, - mcpg_port: u16, - api_key: &str, - mcp_config_path: &Path, -) { - println!("\n=== Probing MCP backends ==="); - - let config_str = match tokio::fs::read_to_string(mcp_config_path).await { - Ok(s) => s, - Err(e) => { - eprintln!("Warning: Could not read MCP config for probing: {}", e); - return; - } - }; - let config: serde_json::Value = match serde_json::from_str(&config_str) { - Ok(v) => v, - Err(e) => { - eprintln!("Warning: Could not parse MCP config for probing: {}", e); - return; - } - }; - - let servers = match config.get("mcpServers").and_then(|s| s.as_object()) { - Some(s) => s, - None => { - eprintln!("Warning: No mcpServers found in MCP config"); - return; - } - }; - - let mut any_failed = false; - - for server_name in servers.keys() { - print!(" {} ... ", server_name); - - // Extract the server's URL to determine the routed path - let server_url = format!("http://127.0.0.1:{}/mcp/{}", mcpg_port, server_name); - - // Step 1: MCP initialize handshake - let init_body = serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": { - "name": "ado-aw-probe", - "version": "1.0" - } - } - }); - - // MCPG expects the raw API key in Authorization (not Bearer scheme) - let init_result = client - .post(&server_url) - .header("Authorization", api_key) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .timeout(std::time::Duration::from_secs(120)) - .json(&init_body) - .send() - .await; - - let session_id = match init_result { - Ok(resp) => { - let session = resp - .headers() - .get("mcp-session-id") - .and_then(|v| v.to_str().ok()) - .map(String::from); - if session.is_none() { - println!("⚠ no session ID returned"); - any_failed = true; - continue; - } - session.unwrap() - } - Err(e) => { - println!("✗ initialize failed: {}", e); - any_failed = true; - continue; - } - }; - - // Step 2: tools/list with session ID - let list_body = serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list" - }); - - let list_result = client - .post(&server_url) - .header("Authorization", api_key) - .header("Content-Type", "application/json") - .header("Accept", "application/json, text/event-stream") - .header("Mcp-Session-Id", &session_id) - .timeout(std::time::Duration::from_secs(120)) - .json(&list_body) - .send() - .await; - - match list_result { - Ok(resp) if resp.status().is_success() => { - // Try to extract tool count from the response body. - // MCPG may return SSE (text/event-stream) or plain JSON. - let body = resp.text().await.unwrap_or_default(); - let tool_count = extract_tool_count(&body); - match tool_count { - Some(n) => println!("✓ {} tools available", n), - None => println!("✓ ready (tool count unknown)"), - } - } - Ok(resp) => { - println!("⚠ tools/list returned HTTP {}", resp.status()); - any_failed = true; - } - Err(e) => { - println!("✗ tools/list failed: {}", e); - any_failed = true; - } - } - } - - if any_failed { - println!("\n ⚠ One or more MCP backends failed to initialize — check MCPG logs"); - } - println!(); -} - -/// Extract tool count from an MCP tools/list response body. -/// Handles both plain JSON and SSE (text/event-stream with `data:` lines). -fn extract_tool_count(body: &str) -> Option { - // Try SSE format first: look for `data: {...}` lines - for line in body.lines() { - if let Some(data) = line.strip_prefix("data: ").or_else(|| line.strip_prefix("data:")) { - if let Ok(v) = serde_json::from_str::(data.trim()) { - if let Some(tools) = v.pointer("/result/tools").and_then(|t| t.as_array()) { - return Some(tools.len()); - } - } - } - } - // Try plain JSON - if let Ok(v) = serde_json::from_str::(body) { - if let Some(tools) = v.pointer("/result/tools").and_then(|t| t.as_array()) { - return Some(tools.len()); - } - } - None -} - -/// Dump MCPG log files to stderr for diagnostics. Called when MCPG fails to -/// start or crashes during health check. Reads stderr.log (MCPG's own output) -/// and mcp-gateway.log (unified per-server log) if they exist. -fn dump_mcpg_logs(mcpg_log_dir: &Path) { - eprintln!("\n--- MCPG diagnostic logs ---"); - - let stderr_log = mcpg_log_dir.join("stderr.log"); - if stderr_log.exists() { - if let Ok(content) = std::fs::read_to_string(&stderr_log) { - let content = content.trim(); - if !content.is_empty() { - eprintln!("\n[stderr.log]:"); - // Limit output to last 100 lines to avoid flooding the terminal - let lines: Vec<&str> = content.lines().collect(); - let start = lines.len().saturating_sub(100); - for line in &lines[start..] { - eprintln!(" {}", line); - } - if start > 0 { - eprintln!(" ... ({} earlier lines omitted)", start); - } - } - } - } - - let gateway_log = mcpg_log_dir.join("mcp-gateway.log"); - if gateway_log.exists() { - if let Ok(content) = std::fs::read_to_string(&gateway_log) { - let content = content.trim(); - if !content.is_empty() { - eprintln!("\n[mcp-gateway.log]:"); - let lines: Vec<&str> = content.lines().collect(); - let start = lines.len().saturating_sub(50); - for line in &lines[start..] { - eprintln!(" {}", line); - } - if start > 0 { - eprintln!(" ... ({} earlier lines omitted)", start); - } - } - } - } - - // List any per-server log files for hints - if let Ok(entries) = std::fs::read_dir(mcpg_log_dir) { - let server_logs: Vec<_> = entries - .filter_map(|e| e.ok()) - .filter(|e| { - let name = e.file_name().to_string_lossy().to_string(); - name.ends_with(".log") - && name != "stderr.log" - && name != "mcp-gateway.log" - }) - .collect(); - if !server_logs.is_empty() { - eprintln!("\nPer-server log files:"); - for entry in &server_logs { - eprintln!(" {}", entry.path().display()); - } - } - } - - eprintln!("--- end MCPG logs ---\n"); -} - -/// Start the MCPG Docker container. Returns the `docker run` child process -/// and the env file handle. Both must be stored in `CleanupGuard`: -/// - The child is reaped after `docker stop` -/// - The env file must outlive the child because `spawn()` returns after -/// fork() — the Docker CLI hasn't yet exec'd or read `--env-file` -/// -/// `gateway_output_path` receives MCPG's stdout — the runtime gateway config -/// JSON that is later transformed into the copilot MCP client config. -fn start_mcpg( - mcpg_config_json: &str, - mcpg_api_key: &str, - port: u16, - gateway_output_path: &Path, - mcpg_log_dir: &Path, - pat: Option<&str>, - needs_ado_token: bool, - debug: bool, -) -> Result<(Child, tempfile::NamedTempFile)> { - // Remove stale container - let _ = Command::new("docker") - .args(["rm", "-f", "ado-aw-mcpg"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - - // Write secrets to a temp file to avoid exposing in ps/cmdline. - // The caller must keep the returned NamedTempFile alive — spawn() is - // just fork(), so the Docker CLI reads --env-file after exec, which - // happens after spawn() returns. - let env_file = tempfile::NamedTempFile::new() - .context("Failed to create temp env file for MCPG secrets")?; - let mut env_contents = format!( - "MCP_GATEWAY_PORT={}\nMCP_GATEWAY_DOMAIN=127.0.0.1\nMCP_GATEWAY_API_KEY={}\n", - port, mcpg_api_key, - ); - if needs_ado_token { - if let Some(pat) = pat { - // Set both vars so MCPG can passthrough whichever the auth mode needs: - // PERSONAL_ACCESS_TOKEN — read by ADO MCP in `-a pat` mode (local dev). - // The ADO MCP returns this value as-is for Basic auth, so it must be - // base64(":") per the microsoft/azure-devops-mcp auth contract. - // AZURE_DEVOPS_EXT_PAT — used by copilot and execute stages (raw PAT) - use base64::Engine; - let b64_pat = - base64::engine::general_purpose::STANDARD.encode(format!(":{}", pat)); - env_contents.push_str(&format!("PERSONAL_ACCESS_TOKEN={}\n", b64_pat)); - env_contents.push_str(&format!("AZURE_DEVOPS_EXT_PAT={}\n", pat)); - } - } - std::fs::write(env_file.path(), &env_contents) - .with_context(|| format!("Failed to write MCPG env file: {}", env_file.path().display()))?; - - // Enable verbose MCPG logging in debug mode. This sets DEBUG on the - // MCPG gateway process itself. Note: do NOT set DEBUG in individual - // server configs (McpgServerConfig.env) — MCPG passes those to child - // MCP containers where the npm `debug` package may write to stdout, - // corrupting the JSON-RPC stdio protocol. - if debug { - let mut contents = std::fs::read_to_string(env_file.path()) - .with_context(|| "Failed to re-read MCPG env file for DEBUG injection")?; - contents.push_str("DEBUG=*\n"); - std::fs::write(env_file.path(), &contents) - .with_context(|| "Failed to write DEBUG env to MCPG env file")?; - } - - let mut args = vec![ - "run".to_string(), - "-i".to_string(), - "--rm".to_string(), - "--name".to_string(), - "ado-aw-mcpg".to_string(), - ]; - - // Network strategy differs by platform: - // Linux: --network host shares the host stack; 127.0.0.1 works both ways. - // Windows/macOS: Docker Desktop runs containers in a VM. --network host doesn't - // expose container ports on the host. Use -p for port mapping and - // host.docker.internal for container→host communication. - if cfg!(target_os = "linux") { - args.extend(["--network".to_string(), "host".to_string()]); - } else { - args.extend(["-p".to_string(), format!("{}:{}", port, port)]); - } - - args.extend([ - "--entrypoint".to_string(), - "/app/awmg".to_string(), - "-v".to_string(), - "/var/run/docker.sock:/var/run/docker.sock".to_string(), - ]); - - // Mount the MCPG log dir to the host so logs survive container removal (--rm). - std::fs::create_dir_all(mcpg_log_dir) - .with_context(|| format!("Failed to create MCPG log dir: {}", mcpg_log_dir.display()))?; - args.extend([ - "-v".to_string(), - format!("{}:/tmp/gh-aw/mcp-logs", mcpg_log_dir.to_string_lossy()), - ]); - - args.extend([ - "--env-file".to_string(), - env_file.path().to_string_lossy().into_owned(), - ]); - - args.push(format!("{}:v{}", compile::MCPG_IMAGE, compile::MCPG_VERSION)); - args.push("--routed".to_string()); - args.push("--listen".to_string()); - // Linux (--network host): bind to loopback only. - // Windows/macOS (-p mapping): bind to 0.0.0.0 so Docker can forward traffic. - if cfg!(target_os = "linux") { - args.push(format!("127.0.0.1:{}", port)); - } else { - args.push(format!("0.0.0.0:{}", port)); - } - args.push("--config-stdin".to_string()); - args.push("--log-dir".to_string()); - args.push("/tmp/gh-aw/mcp-logs".to_string()); - - // Redirect stdout to the gateway output file — MCPG writes its runtime - // config (with actual URLs) to stdout once it finishes initialising servers. - let gateway_file = std::fs::File::create(gateway_output_path) - .with_context(|| format!("Failed to create gateway output file: {}", gateway_output_path.display()))?; - - // Stderr handling: - // debug=true → inherit to terminal for live visibility - // debug=false → capture to stderr.log (not lost, just quiet) - let stderr_cfg = if debug { - Stdio::inherit() - } else { - let stderr_path = mcpg_log_dir.join("stderr.log"); - let stderr_file = std::fs::File::create(&stderr_path) - .with_context(|| format!("Failed to create MCPG stderr log: {}", stderr_path.display()))?; - Stdio::from(stderr_file) - }; - - let mut child = Command::new("docker") - .args(&args) - .stdin(Stdio::piped()) - .stdout(gateway_file) - .stderr(stderr_cfg) - .spawn() - .context("Failed to start MCPG Docker container")?; - - // Pipe config to stdin, then close so container sees EOF - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(mcpg_config_json.as_bytes()) - .context("Failed to write MCPG config to stdin")?; - drop(stdin); - } - - // Caller must keep env_file alive until Docker has read it - Ok((child, env_file)) -} - -/// Transform MCPG's gateway output JSON into a copilot-compatible MCP client config. -/// -/// Mirrors the pipeline's `jq` transformation: -/// - Keep URLs as-is (local run: copilot runs on host, same as MCPG) -/// - Ensure `tools: ["*"]` on each server entry (Copilot CLI requirement) -/// - Preserve headers and other fields -fn transform_gateway_output(gateway_json: &str) -> Result { - let mut config: serde_json::Value = serde_json::from_str(gateway_json) - .context("Failed to parse MCPG gateway output as JSON")?; - - let servers = config - .get_mut("mcpServers") - .and_then(|v| v.as_object_mut()) - .context("Gateway output missing mcpServers")?; - - for (_name, entry) in servers.iter_mut() { - if let Some(obj) = entry.as_object_mut() { - obj.insert( - "tools".into(), - serde_json::Value::Array(vec![serde_json::Value::String("*".into())]), - ); - } - } - - serde_json::to_string_pretty(&config) - .context("Failed to serialize MCP client config") -} - -/// Build a `std::process::Command` for a program that may be a script wrapper. -/// -/// On Windows, npm-installed tools like `copilot` are `.cmd`/`.ps1` wrappers. -/// `Command::new("copilot")` won't find them — `cmd /C` resolves `.cmd`/`.bat` -/// from PATH and handles execution natively. -/// On Unix (Linux/macOS), `Command::new` resolves scripts via the shebang. -#[allow(dead_code)] -fn host_command(program: &str) -> Command { - if cfg!(windows) { - let mut cmd = Command::new("cmd"); - cmd.args(["/C", program]); - cmd - } else { - Command::new(program) - } -} - -/// Async variant of [`host_command`] using `tokio::process::Command`. -fn host_command_async(program: &str) -> tokio::process::Command { - if cfg!(windows) { - let mut cmd = tokio::process::Command::new("cmd"); - cmd.args(["/C", program]); - cmd - } else { - tokio::process::Command::new(program) - } -} - -/// Check if an executable is available on PATH. -/// Uses `where` (Windows) or `which` (Unix) to avoid running the program. -fn is_on_path(name: &str) -> bool { - let (checker, args) = if cfg!(windows) { - ("where", vec![name.to_string()]) - } else { - ("which", vec![name.to_string()]) - }; - Command::new(checker) - .args(&args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -/// Walk up from `start` to find the nearest directory containing `.git`. -fn find_repo_root(start: &Path) -> Option { - let mut current = if start.is_absolute() { - start.to_path_buf() - } else { - std::env::current_dir().ok()?.join(start) - }; - loop { - if current.join(".git").exists() { - return Some(current); - } - if !current.pop() { - return None; - } - } -} - -pub async fn run(args: &RunArgs) -> Result<()> { - // ── 1. Parse agent markdown ────────────────────────────────────── - let content = tokio::fs::read_to_string(&args.agent_path) - .await - .with_context(|| format!("Failed to read agent file: {}", args.agent_path.display()))?; - - let (mut front_matter, markdown_body) = compile::parse_markdown(&content)?; - front_matter.sanitize_config_fields(); - - println!("=== ado-aw run: {} ===", front_matter.name); - println!("Description: {}", front_matter.description); - println!("Engine: {} (model: {})", front_matter.engine.engine_id(), front_matter.engine.model().unwrap_or("default")); - if args.dry_run { - println!("Mode: dry-run (ADO API calls will be skipped in execute stage)"); - } - - // If --org is provided and tools.azure-devops has no explicit org, - // inject it so AzureDevOpsExtension picks it up during config generation. - // Sanitize the value since it's injected after sanitize_config_fields(). - if let Some(org) = &args.org { - if let Some(ref mut tools) = front_matter.tools { - if let Some(ref mut ado) = tools.azure_devops { - if ado.org().is_none() { - ado.set_org(crate::sanitize::sanitize_config(org)); - } - } - } - } - - // ── 2. Collect extensions ──────────────────────────────────────── - // Local run uses PAT auth for ADO MCP (users have PATs, not bearer JWTs). - let extensions = compile::extensions::collect_extensions_with_auth( - &front_matter, - compile::extensions::AdoAuthMode::Pat, - ); - - // ── 3. Create output directory ─────────────────────────────────── - let output_dir = match &args.output_dir { - Some(dir) => { - tokio::fs::create_dir_all(dir).await - .with_context(|| format!("Failed to create output directory: {}", dir.display()))?; - dir.clone() - } - None => { - let dir = std::env::temp_dir().join(format!("ado-aw-run-{}", std::process::id())); - tokio::fs::create_dir_all(&dir).await - .with_context(|| format!("Failed to create output directory: {}", dir.display()))?; - dir - } - }; - - // Working directory: use the repository root (where .git lives) so that - // safe-output tools like create-pull-request operate on the full repo, - // not just the subdirectory containing the agent file. - let agent_dir = args - .agent_path - .parent() - .unwrap_or(Path::new(".")); - let working_dir = find_repo_root(agent_dir) - .unwrap_or_else(|| agent_dir.to_path_buf()); - // Resolve the full path for display. On Windows, std canonicalize() - // returns UNC-style `\\?\` paths which break many tools. Use - // dunce-style stripping to get a normal path. - let output_dir = match output_dir.canonicalize() { - Ok(p) => strip_unc_prefix(p), - Err(_) => output_dir, - }; - println!("Output directory: {}", output_dir.display()); - println!("Working directory: {}", working_dir.display()); - if args.debug { - println!("Debug log locations:"); - println!(" Copilot logs: ~/.copilot/logs/"); - println!(" SafeOutputs logs: {}", output_dir.join("logs").display()); - } - - // ── 4. Start SafeOutputs HTTP server ───────────────────────────── - let mut guard = CleanupGuard { - safeoutputs_child: None, - mcpg_child: None, - mcpg_env_file: None, - }; - - println!("\n=== Starting SafeOutputs HTTP server ==="); - let (child, so_port, so_api_key) = - start_safeoutputs(&output_dir, &working_dir, &[]).await?; - guard.safeoutputs_child = Some(child); - println!("SafeOutputs ready on port {}", so_port); - - // ── 5. Generate configs + optionally start MCPG ────────────────── - let mcpg_api_key = generate_api_key(); - let mcp_config_path = output_dir.join("mcp-config.json"); - - // Check if any MCP or ADO tool needs a token - let needs_ado_token = front_matter - .tools - .as_ref() - .and_then(|t| t.azure_devops.as_ref()) - .is_some_and(|ado| ado.is_enabled()) - || front_matter.mcp_servers.values().any(|config| { - matches!(config, compile::types::McpConfig::WithOptions(opts) - if opts.enabled.unwrap_or(true) - && opts.container.is_some() - && opts.env.contains_key("AZURE_DEVOPS_EXT_PAT")) - }); - - let use_mcpg = !args.skip_mcpg && is_docker_available(); - if !args.skip_mcpg && !use_mcpg { - warn!("Docker is not available — falling back to --skip-mcpg mode"); - println!("Warning: Docker not available, running without MCPG"); - } - - // Build compile context (resolves engine + ADO context) — needed by both - // MCPG config generation and copilot params generation. - let compile_ctx = - compile::extensions::CompileContext::new(&front_matter, &working_dir).await?; - - if use_mcpg { - // Pick a free high port for MCPG — port 80 (used in pipelines) requires - // elevated privileges on most systems and isn't suitable for local dev. - let mcpg_port = find_free_port() - .context("Failed to find a free port for MCPG")?; - - println!("\n=== Generating MCPG config ==="); - - let mut mcpg_config = - compile::generate_mcpg_config(&front_matter, &compile_ctx, &extensions)?; - mcpg_config.gateway.port = mcpg_port; - - // Serialize and substitute runtime placeholders - let mcpg_json = serde_json::to_string_pretty(&mcpg_config) - .context("Failed to serialize MCPG config")?; - let mcpg_json = mcpg_json - .replace("${SAFE_OUTPUTS_PORT}", &so_port.to_string()) - .replace("${SAFE_OUTPUTS_API_KEY}", &so_api_key) - .replace("${MCP_GATEWAY_API_KEY}", &mcpg_api_key); - - // Rewrite SafeOutputs URL for the container→host network path. - // The compile-time config uses "localhost" which works in pipelines - // (Linux, --network host shares the host stack). Locally: - // - Linux: "localhost" may resolve to ::1 (IPv6) but SafeOutputs - // binds IPv4 only, so use 127.0.0.1 explicitly. - // - Windows/macOS: Docker Desktop runs containers in a VM, so - // "localhost" is the VM loopback. Use host.docker.internal. - let mcpg_json = if cfg!(target_os = "linux") { - mcpg_json.replace("http://localhost:", "http://127.0.0.1:") - } else { - mcpg_json.replace("http://localhost:", "http://host.docker.internal:") - }; - - tokio::fs::write(output_dir.join("mcpg-config.json"), &mcpg_json).await - .with_context(|| format!("Failed to write MCPG config: {}", output_dir.join("mcpg-config.json").display()))?; - debug!("MCPG config written"); - - // Start MCPG - println!("\n=== Starting MCP Gateway (MCPG) ==="); - if needs_ado_token && args.pat.is_none() { - warn!("ADO MCP requires a PAT but none was provided (--pat or AZURE_DEVOPS_EXT_PAT). \ - ADO MCP tool calls will likely fail at runtime."); - println!("Warning: ADO MCP enabled but no PAT provided — tool calls may fail"); - } - let gateway_output_path = output_dir.join("gateway-output.json"); - let mcpg_log_dir = output_dir.join("mcpg-logs"); - if args.debug { - println!("MCPG logs will be written to: {}", mcpg_log_dir.display()); - } - let (mcpg_child, mcpg_env_file) = start_mcpg( - &mcpg_json, - &mcpg_api_key, - mcpg_port, - &gateway_output_path, - &mcpg_log_dir, - args.pat.as_deref(), - needs_ado_token, - args.debug, - )?; - guard.mcpg_child = Some(mcpg_child); - guard.mcpg_env_file = Some(mcpg_env_file); - - // Health check MCPG — also detect early crash - let client = reqwest::Client::new(); - let health_url = format!("http://127.0.0.1:{}/health", mcpg_port); - let mut ready = false; - for _ in 0..30 { - if let Some(ref mut child) = guard.mcpg_child { - if let Some(status) = child.try_wait()? { - dump_mcpg_logs(&mcpg_log_dir); - bail!("MCPG container exited during startup with {}", status); - } - } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - match client.get(&health_url).send().await { - Ok(resp) if resp.status().is_success() => { - ready = true; - break; - } - _ => continue, - } - } - if !ready { - dump_mcpg_logs(&mcpg_log_dir); - bail!("MCPG did not become ready within 30s"); - } - println!("MCPG ready on port {}", mcpg_port); - println!("MCPG logs: {}", mcpg_log_dir.display()); - println!(" Tip: tail -f {}/mcp-gateway.log", mcpg_log_dir.display()); - - // Wait for gateway output — health check passing doesn't guarantee - // stdout is flushed, so poll until the file contains valid JSON. - println!("Waiting for gateway output..."); - let mut gateway_ready = false; - for _ in 0..15 { - if let Ok(content) = tokio::fs::read_to_string(&gateway_output_path).await { - if serde_json::from_str::(&content) - .ok() - .and_then(|v| v.get("mcpServers").cloned()) - .is_some() - { - gateway_ready = true; - break; - } - } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - if !gateway_ready { - let content = tokio::fs::read_to_string(&gateway_output_path).await.unwrap_or_default(); - dump_mcpg_logs(&mcpg_log_dir); - bail!( - "MCPG gateway output not ready within 15s. Content: {}", - if content.is_empty() { "(empty)" } else { &content } - ); - } - - // Transform MCPG's runtime output into copilot client config - let gateway_json = tokio::fs::read_to_string(&gateway_output_path).await - .context("Failed to read MCPG gateway output")?; - debug!("Gateway output: {}", gateway_json); - let mcp_client_json = transform_gateway_output(&gateway_json)?; - - tokio::fs::write(&mcp_config_path, &mcp_client_json).await - .with_context(|| format!("Failed to write MCP config: {}", mcp_config_path.display()))?; - debug!("MCP client config written"); - println!("MCP client config generated from gateway output"); - - // In debug mode, probe each MCP backend to force eager launch and - // surface failures before the agent runs. MCPG lazily starts stdio - // backends on first tool call — without probing, a broken backend - // only surfaces as a silent missing-tool error during the agent run. - if args.debug { - probe_mcp_backends(&client, mcpg_port, &mcpg_api_key, &mcp_config_path).await; - } - } else { - // Skip MCPG — generate direct config pointing to SafeOutputs - println!("\n=== Generating direct MCP config (no MCPG) ==="); - let direct_config = serde_json::json!({ - "mcpServers": { - "safeoutputs": { - "type": "http", - "url": format!("http://127.0.0.1:{}/mcp", so_port), - "headers": { - "Authorization": format!("Bearer {}", so_api_key) - }, - "tools": ["*"] - } - } - }); - let mcp_client_json = serde_json::to_string_pretty(&direct_config) - .context("Failed to serialize direct MCP config")?; - tokio::fs::write(&mcp_config_path, &mcp_client_json).await - .with_context(|| format!("Failed to write MCP config: {}", mcp_config_path.display()))?; - println!("MCP config written (direct SafeOutputs, no MCPG)"); - } - - // ── 6. Write agent prompt ──────────────────────────────────────── - let prompt_path = output_dir.join("agent-prompt.md"); - tokio::fs::write(&prompt_path, &markdown_body).await - .with_context(|| format!("Failed to write agent prompt: {}", prompt_path.display()))?; - debug!("Agent prompt written to {}", prompt_path.display()); - - // ── 7. Build and run copilot command ───────────────────────────── - let copilot_params = compile_ctx.engine.args(compile_ctx.front_matter, &extensions)?; - - println!("\n=== Copilot CLI ==="); - - if is_on_path("copilot") { - let mut cmd = host_command_async("copilot"); - // Collect args for debug logging (tokio Command doesn't expose them) - let mut visible_args: Vec = Vec::new(); - - // Pass prompt via @file to avoid cmd.exe argument length limits on Windows - let prompt_file_ref = format!("@{}", prompt_path.display()); - cmd.arg("--prompt").arg(&prompt_file_ref); - visible_args.push("--prompt".into()); - visible_args.push(prompt_file_ref.clone()); - - let mcp_config_ref = format!("@{}", mcp_config_path.display()); - cmd.arg("--additional-mcp-config") - .arg(&mcp_config_ref); - visible_args.push("--additional-mcp-config".into()); - visible_args.push(mcp_config_ref.clone()); - - // Parse copilot_params and add as args - for param in shell_words(&copilot_params) { - visible_args.push(param.clone()); - cmd.arg(param); - } - - // Debug mode: enable verbose copilot logging. Logs are written to - // the default ~/.copilot/logs/ directory (--log-dir is unreliable - // across platforms). - if args.debug { - cmd.arg("--log-level").arg("debug"); - visible_args.extend(["--log-level".into(), "debug".into()]); - } - - println!("Running: copilot {}", visible_args.join(" ")); - - // Set working directory - cmd.current_dir(&working_dir); - - // Set environment - if let Some(pat) = &args.pat { - cmd.env("AZURE_DEVOPS_EXT_PAT", pat); - cmd.env("SYSTEM_ACCESSTOKEN", pat); - } - - let status = cmd - .status() - .await - .context("Failed to run copilot")?; - - if !status.success() { - warn!("Copilot exited with status: {}", status); - println!("Copilot exited with status: {}", status); - } - } else { - let debug_flags = if args.debug { " --log-level debug" } else { "" }; - println!("Copilot CLI not found on PATH."); - println!("To run the agent, execute this command:\n"); - println!( - " copilot --prompt @{} --additional-mcp-config @{} {}{}\n", - prompt_path.display(), - mcp_config_path.display(), - copilot_params, - debug_flags, - ); - - if let Some(pat) = &args.pat { - println!("With environment:"); - println!(" export AZURE_DEVOPS_EXT_PAT=\"{}...\"", &pat[..4.min(pat.len())]); - } - - println!("\nPress Enter after the agent completes to continue with execution..."); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - } - - // ── 8. Execute safe outputs ────────────────────────────────────── - println!("\n=== Executing safe outputs ==="); - - let mut ctx = crate::safeoutputs::ExecutionContext::default(); - ctx.dry_run = args.dry_run; - ctx.working_directory = output_dir.clone(); - ctx.source_directory = working_dir.clone(); - ctx.tool_configs = front_matter.safe_outputs.clone(); - - if let Some(org) = &args.org { - ctx.ado_org_url = Some(org.clone()); - } - if let Some(project) = &args.project { - ctx.ado_project = Some(project.clone()); - } - if let Some(pat) = &args.pat { - ctx.access_token = Some(pat.clone()); - } - - // Build allowed repositories mapping - let mut allowed_repositories = HashMap::new(); - for checkout_alias in &front_matter.checkout { - if let Some(repo) = front_matter - .repositories - .iter() - .find(|r| &r.repository == checkout_alias) - { - allowed_repositories.insert(checkout_alias.clone(), repo.name.clone()); - } - } - ctx.allowed_repositories = allowed_repositories; - - let results = crate::execute::execute_safe_outputs(&output_dir, &ctx).await?; - - // Print summary - let success_count = results.iter().filter(|r| r.success && !r.is_warning()).count(); - let warning_count = results.iter().filter(|r| r.is_warning()).count(); - let failure_count = results.iter().filter(|r| !r.success).count(); - - println!("\n--- Execution Summary ---"); - println!( - "Total: {} | Success: {} | Warnings: {} | Failed: {}", - results.len(), - success_count, - warning_count, - failure_count - ); - - // ── 9. Cleanup (via CleanupGuard drop) ─────────────────────────── - println!("\n=== Cleanup ==="); - drop(guard); - println!("Done."); - - // process::exit skips async runtime teardown. This is safe because: - // - CleanupGuard is explicitly dropped above (child processes reaped) - // - No tokio background tasks are spawned after the guard is set - // If background tasks are added in the future, they must complete before this point. - if failure_count > 0 { - std::process::exit(1); - } - - Ok(()) -} - -/// Simple shell-like word splitting for copilot params. -/// -/// Handles double-quoted strings (e.g., `--allow-tool "shell(cat)"`). -/// Does NOT handle backslash escapes, single quotes, or nested quotes. -/// -/// This is safe because the input is compiler-controlled output from -/// `Engine::args()`, which only produces double-quoted values -/// with no escapes. If params ever gain more complex quoting, consider -/// using the `shell-words` crate. -fn shell_words(s: &str) -> Vec { - let mut words = Vec::new(); - let mut current = String::new(); - let mut in_quotes = false; - - for ch in s.chars() { - match ch { - '"' => in_quotes = !in_quotes, - ' ' if !in_quotes => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - } - _ => current.push(ch), - } - } - if !current.is_empty() { - words.push(current); - } - - words -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_generate_api_key_is_alphanumeric() { - let key = generate_api_key(); - assert!(!key.is_empty(), "API key should not be empty"); - assert!(key.len() >= 40, "API key should be at least 40 chars, got {}", key.len()); - assert!( - key.chars().all(|c| c.is_ascii_alphanumeric()), - "API key should be alphanumeric, got: {}", - key - ); - } - - #[test] - fn test_find_free_port() { - let port = find_free_port().unwrap(); - assert!(port > 0, "Port should be positive"); - } - - #[test] - fn test_shell_words_simple() { - let words = shell_words("--model claude-opus-4.5 --no-ask-user"); - assert_eq!(words, vec!["--model", "claude-opus-4.5", "--no-ask-user"]); - } - - #[test] - fn test_shell_words_quoted() { - let words = shell_words(r#"--allow-tool "shell(cat)" --allow-tool write"#); - assert_eq!(words, vec!["--allow-tool", "shell(cat)", "--allow-tool", "write"]); - } - - #[test] - fn test_shell_words_empty() { - let words = shell_words(""); - assert!(words.is_empty()); - } - - #[test] - fn test_skip_mcpg_direct_config_structure() { - // Verify the direct SafeOutputs config has the right structure - let port = 8100u16; - let api_key = "test-key"; - let config = serde_json::json!({ - "mcpServers": { - "safeoutputs": { - "type": "http", - "url": format!("http://127.0.0.1:{}/mcp", port), - "headers": { - "Authorization": format!("Bearer {}", api_key) - }, - "tools": ["*"] - } - } - }); - - let json = serde_json::to_string_pretty(&config).unwrap(); - assert!(json.contains("127.0.0.1:8100")); - assert!(json.contains("Bearer test-key")); - assert!(json.contains("\"type\": \"http\"")); - } - - #[test] - fn test_transform_gateway_output() { - let gateway_json = serde_json::json!({ - "mcpServers": { - "safeoutputs": { - "type": "http", - "url": "http://127.0.0.1:54321/mcp/safeoutputs", - "headers": { - "Authorization": "Bearer secret-key" - } - }, - "azure-devops": { - "type": "http", - "url": "http://127.0.0.1:54321/mcp/azure-devops" - } - } - }); - - let result = transform_gateway_output(&gateway_json.to_string()).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); - - // Each server should have tools: ["*"] - let servers = parsed["mcpServers"].as_object().unwrap(); - for (name, entry) in servers { - assert_eq!( - entry["tools"], - serde_json::json!(["*"]), - "Server '{}' should have tools: [\"*\"]", name, - ); - } - - // URLs should be preserved as-is (local run, no rewriting needed) - assert!(result.contains("127.0.0.1:54321")); - - // Headers should be preserved - assert!(result.contains("Bearer secret-key")); - } -} diff --git a/src/tools/azure_devops/extension.rs b/src/tools/azure_devops/extension.rs index 667fd0c5..63f40ce3 100644 --- a/src/tools/azure_devops/extension.rs +++ b/src/tools/azure_devops/extension.rs @@ -17,35 +17,14 @@ use std::collections::HashMap; /// ADO MCP), and compile-time validation (org inference, duplicate MCP). pub struct AzureDevOpsExtension { config: AzureDevOpsToolConfig, - auth_mode: AdoAuthMode, -} - -/// Authentication mode for the ADO MCP server. -/// -/// Pipelines use bearer tokens (JWT from ARM service connections). -/// Local development uses PATs (Personal Access Tokens). -#[derive(Debug, Clone, Copy, Default)] -pub enum AdoAuthMode { - /// `-a envvar` + `ADO_MCP_AUTH_TOKEN` — bearer JWT from ARM (pipeline default) - #[default] - Bearer, - /// `-a pat` + `AZURE_DEVOPS_EXT_PAT` — Personal Access Token (local dev) - Pat, } impl AzureDevOpsExtension { pub fn new(config: AzureDevOpsToolConfig) -> Self { Self { config, - auth_mode: AdoAuthMode::default(), } } - - /// Set the authentication mode (e.g., `AdoAuthMode::Pat` for local runs). - pub fn with_auth_mode(mut self, mode: AdoAuthMode) -> Self { - self.auth_mode = mode; - self - } } impl CompilerExtension for AzureDevOpsExtension { @@ -124,12 +103,8 @@ impl CompilerExtension for AzureDevOpsExtension { // ADO MCP authentication: the @azure-devops/mcp npm package accepts // auth type via CLI arg (-a) and token via env var. - // Bearer: `-a envvar` reads ADO_MCP_AUTH_TOKEN (pipeline JWT from ARM) - // Pat: `-a pat` reads PERSONAL_ACCESS_TOKEN (base64-encoded PAT) - let (auth_flag, token_var) = match self.auth_mode { - AdoAuthMode::Bearer => ("envvar", "ADO_MCP_AUTH_TOKEN"), - AdoAuthMode::Pat => ("pat", "PERSONAL_ACCESS_TOKEN"), - }; + // Bearer: `-a envvar` reads ADO_MCP_AUTH_TOKEN (pipeline JWT from ARM) + let (auth_flag, token_var) = ("envvar", "ADO_MCP_AUTH_TOKEN"); entrypoint_args.extend(["-a".to_string(), auth_flag.to_string()]); let env = Some(HashMap::from([( @@ -180,14 +155,9 @@ impl CompilerExtension for AzureDevOpsExtension { Ok(warnings) } fn required_pipeline_vars(&self) -> Vec { - match self.auth_mode { - AdoAuthMode::Bearer => vec![PipelineEnvMapping { - container_var: "ADO_MCP_AUTH_TOKEN".to_string(), - pipeline_var: "SC_READ_TOKEN".to_string(), - }], - // PAT mode: no pipeline var mapping needed — the PAT is passed - // directly via AZURE_DEVOPS_EXT_PAT in the MCPG env file. - AdoAuthMode::Pat => vec![], - } + vec![PipelineEnvMapping { + container_var: "ADO_MCP_AUTH_TOKEN".to_string(), + pipeline_var: "SC_READ_TOKEN".to_string(), + }] } } diff --git a/src/tools/azure_devops/mod.rs b/src/tools/azure_devops/mod.rs index 1cf2a5eb..f1eebbb0 100644 --- a/src/tools/azure_devops/mod.rs +++ b/src/tools/azure_devops/mod.rs @@ -6,4 +6,4 @@ pub mod extension; -pub use extension::{AdoAuthMode, AzureDevOpsExtension}; +pub use extension::AzureDevOpsExtension; diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index b7e48989..5237254a 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,25 +1,7 @@ use std::path::PathBuf; -#[cfg(debug_assertions)] #[test] -fn test_run_subcommand_exposed_in_debug_builds() { - let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); - let output = std::process::Command::new(&binary_path) - .arg("--help") - .output() - .expect("Failed to run ado-aw --help"); - - assert!(output.status.success(), "--help should succeed"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("Run agent locally"), - "Debug build help output should include the run subcommand, got:\n{stdout}" - ); -} - -#[cfg(not(debug_assertions))] -#[test] -fn test_run_subcommand_not_exposed_in_release_builds() { +fn test_run_subcommand_not_present() { let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); let output = std::process::Command::new(&binary_path) .arg("--help") @@ -30,6 +12,6 @@ fn test_run_subcommand_not_exposed_in_release_builds() { let stdout = String::from_utf8_lossy(&output.stdout); assert!( !stdout.contains("Run agent locally"), - "Release build help output should not include the run subcommand, got:\n{stdout}" + "Help output should not include a run subcommand, got:\n{stdout}" ); }