diff --git a/AGENTS.md b/AGENTS.md index e7f7bd1..21d30ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,8 +121,9 @@ The compiler expects markdown files with YAML front matter similar to gh-aw: name: "name for this agent" description: "One line description for this agent" target: standalone # Optional: "standalone" (default) or "1es". See Target Platforms section below. -engine: claude-opus-4.5 # AI engine to use. Defaults to claude-opus-4.5. Other options include claude-sonnet-4.5, gpt-5.2-codex, gemini-3-pro-preview, etc. +engine: copilot # Engine identifier. Defaults to copilot. Currently only 'copilot' (GitHub Copilot CLI) is supported. # engine: # Alternative object format (with additional options) +# id: copilot # model: claude-opus-4.5 # timeout-minutes: 30 schedule: daily around 14:00 # Fuzzy schedule syntax - see Schedule Syntax section below @@ -314,14 +315,15 @@ schedule: ### Engine Configuration -The `engine` field specifies which AI model to use and optional execution parameters. It accepts both a simple string format (model name only) and an object format with additional options. +The `engine` field specifies which engine to use for the agentic task. The string form is an engine identifier (currently only `copilot` is supported). The object form uses `id` for the engine identifier plus additional options like model selection and timeout. ```yaml -# Simple string format (just a model name) -engine: claude-opus-4.5 +# Simple string format (engine identifier, defaults to copilot) +engine: copilot # Object format with additional options engine: + id: copilot model: claude-opus-4.5 timeout-minutes: 30 ``` @@ -330,11 +332,20 @@ engine: | Field | Type | Default | Description | |-------|------|---------|-------------| +| `id` | string | `copilot` | Engine identifier. Currently only `copilot` (GitHub Copilot CLI) is supported. | | `model` | string | `claude-opus-4.5` | AI model to use. Options include `claude-sonnet-4.5`, `gpt-5.2-codex`, `gemini-3-pro-preview`, etc. | | `timeout-minutes` | integer | *(none)* | Maximum time in minutes the agent job is allowed to run. Sets `timeoutInMinutes` on the `Agent` job in the generated pipeline. | +| `version` | string | *(none)* | Engine CLI version to install (e.g., `"0.0.422"`, `"latest"`). **Not yet wired** — parsed but ignored with a warning. | +| `agent` | string | *(none)* | Custom agent file identifier (Copilot only). **Not yet wired** — parsed but ignored with a warning. | +| `api-target` | string | *(none)* | Custom API endpoint hostname for GHES/GHEC (e.g., `"api.acme.ghe.com"`). **Not yet wired** — parsed but ignored with a warning. | +| `args` | list | `[]` | Custom CLI arguments injected before the prompt. **Not yet wired** — parsed but ignored with a warning. | +| `env` | map | *(none)* | Engine-specific environment variables. **Not yet wired** — parsed but ignored with a warning. | +| `command` | string | *(none)* | Custom engine executable path (skips default installation). **Not yet wired** — parsed but ignored with a warning. | > **Deprecated:** `max-turns` is still accepted in front matter for backwards compatibility but is ignored at compile time (a warning is emitted). It was specific to Claude Code and is not supported by Copilot CLI. +> **Note:** Fields marked "not yet wired" are accepted in the schema for forward compatibility with gh-aw but produce a compile-time warning. Pipeline wiring for these fields is incremental. + #### `timeout-minutes` The `timeout-minutes` field sets a wall-clock limit (in minutes) for the entire agent job. It maps to the Azure DevOps `timeoutInMinutes` job property on `Agent`. This is useful for: @@ -838,29 +849,6 @@ Tool names are validated at compile time: - Names must contain only ASCII alphanumerics and hyphens (shell injection prevention) - Unrecognized names (not in `ALL_KNOWN_SAFE_OUTPUTS`) emit a warning to catch typos -## {{ cancel_previous_builds }} - -When `triggers.pipeline` is configured, this generates a bash step that cancels any previously queued or in-progress builds of the same pipeline definition. This prevents multiple builds from accumulating when the upstream pipeline triggers rapidly (e.g., multiple PRs merged in quick succession). - -The step: -- Uses the Azure DevOps REST API to query builds for the current pipeline definition -- Filters to only `notStarted` and `inProgress` builds -- Excludes the current build from cancellation -- Cancels each older build via PATCH request - -Example output: -```yaml -- bash: | - CURRENT_BUILD_ID=$(Build.BuildId) - BUILDS=$(curl -s -u ":$SYSTEM_ACCESSTOKEN" \ - "$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds?definitions=$(System.DefinitionId)&statusFilter=notStarted,inProgress&api-version=7.1" \ - | jq -r --arg current "$CURRENT_BUILD_ID" '.value[] | select(.id != ($current | tonumber)) | .id') - # ... cancels each build - displayName: "Cancel previous queued builds" - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) -``` - ## {{ threat_analysis_prompt }} Should be replaced with the embedded threat detection analysis prompt from `src/data/threat-analysis.md`. This prompt template includes markers for `{{ source_path }}`, `{{ agent_name }}`, `{{ agent_description }}`, and `{{ working_directory }}` which are replaced during compilation. diff --git a/examples/azure-devops-mcp.md b/examples/azure-devops-mcp.md index 5c71709..fe65a2c 100644 --- a/examples/azure-devops-mcp.md +++ b/examples/azure-devops-mcp.md @@ -2,7 +2,6 @@ name: "ADO Work Item Triage" description: "Triages work items using the Azure DevOps MCP" schedule: daily around 9:00 -engine: claude-sonnet-4.5 tools: azure-devops: toolsets: [core, work, work-items] diff --git a/examples/lean-verifier.md b/examples/lean-verifier.md index e9672d8..ebb4698 100644 --- a/examples/lean-verifier.md +++ b/examples/lean-verifier.md @@ -1,7 +1,6 @@ --- name: "Lean Formal Verifier" description: "Analyzes code and builds formal Lean 4 proofs of critical invariants" -engine: claude-opus-4.5 schedule: weekly on friday around 17:00 tools: cache-memory: true diff --git a/prompts/create-ado-agentic-workflow.md b/prompts/create-ado-agentic-workflow.md index af69a71..f461056 100644 --- a/prompts/create-ado-agentic-workflow.md +++ b/prompts/create-ado-agentic-workflow.md @@ -62,20 +62,23 @@ name: "Weekly Dependency Updater" description: "Checks for outdated dependencies and opens PRs to update them" ``` -### Step 2 — AI Model (engine) +### Step 2 — Engine -Default is `claude-opus-4.5`. Only include `engine:` if the user requests a different model. +Default engine is `copilot` (GitHub Copilot CLI). The `engine:` field is an engine identifier, not a model name. Only include `engine:` if you need to set a non-default model or timeout. -| Value | Use when | +The default model is `claude-opus-4.5`. To use a different model, use the object form: + +| Model | Use when | |---|---| | `claude-opus-4.5` | Default. Best reasoning, complex tasks. | | `claude-sonnet-4.5` | Faster, cheaper, simpler tasks. | | `gpt-5.2-codex` | Code-heavy tasks. | | `gemini-3-pro-preview` | Google ecosystem tasks. | -Object form with extra options: +Object form with model selection and extra options: ```yaml engine: + id: copilot model: claude-sonnet-4.5 timeout-minutes: 30 ``` @@ -427,7 +430,9 @@ Use `noop` with a brief summary of what was reviewed. --- name: "Dependency Updater" description: "Checks for outdated npm dependencies and opens PRs to update them" -engine: claude-sonnet-4.5 +engine: + id: copilot + model: claude-sonnet-4.5 schedule: weekly on monday around 9:00 tools: azure-devops: true diff --git a/prompts/debug-ado-agentic-workflow.md b/prompts/debug-ado-agentic-workflow.md index 1d3587e..ae30698 100644 --- a/prompts/debug-ado-agentic-workflow.md +++ b/prompts/debug-ado-agentic-workflow.md @@ -133,10 +133,20 @@ network: **Common causes**: -- **Invalid model name**: Check the `engine:` field matches a supported model (`claude-opus-4.5`, `claude-sonnet-4.5`, `gpt-5.2-codex`, `gemini-3-pro-preview`, etc.) +- **Invalid engine or model**: The `engine:` field is an engine identifier (e.g., `copilot`), not a model name. To specify a model, use the object form. Check that the engine identifier is valid and the model name is correct: + ```yaml + # Wrong — model name as engine identifier + engine: claude-opus-4.5 + + # Correct — engine identifier with model + engine: + id: copilot + model: claude-opus-4.5 + ``` - **Timeout**: Agent hits the Azure DevOps job timeout (default 60 minutes). Set an explicit timeout: ```yaml engine: + id: copilot model: claude-opus-4.5 timeout-minutes: 120 ``` diff --git a/src/compile/common.rs b/src/compile/common.rs index b2c793d..5d4996d 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -7,7 +7,6 @@ use std::path::Path; use super::types::{FrontMatter, PipelineParameter, Repository, TriggerConfig}; use super::extensions::{CompilerExtension, Extension, McpgServerConfig, McpgGatewayConfig, McpgConfig, CompileContext}; use crate::compile::types::McpConfig; -use crate::engine::Engine as _; use crate::fuzzy_schedule; use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts}; use crate::ecosystem_domains::{get_ecosystem_domains, is_ecosystem_identifier, is_known_ecosystem}; @@ -366,41 +365,6 @@ pub fn generate_pipeline_resources(triggers: &Option) -> Result) -> String { - let has_pipeline_trigger = triggers - .as_ref() - .and_then(|t| t.pipeline.as_ref()) - .is_some(); - - if !has_pipeline_trigger { - return String::new(); - } - - r#"- bash: | - CURRENT_BUILD_ID=$(Build.BuildId) - - # Get queued or running builds for THIS pipeline definition only - BUILDS=$(curl -s -u ":$SYSTEM_ACCESSTOKEN" \ - "$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds?definitions=$(System.DefinitionId)&statusFilter=notStarted,inProgress&api-version=7.1" \ - | jq -r --arg current "$CURRENT_BUILD_ID" '.value[] | select(.id != ($current | tonumber)) | .id') - - if [ -z "$BUILDS" ]; then - echo "No other queued/running builds to cancel" - else - for BUILD_ID in $BUILDS; do - echo "Cancelling build $BUILD_ID" - curl -s -X PATCH -u ":$SYSTEM_ACCESSTOKEN" \ - -H "Content-Type: application/json" \ - -d '{"status": "cancelling"}' \ - "$(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$BUILD_ID?api-version=7.1" - done - fi - displayName: "Cancel previous queued builds" - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken)"#.to_string() -} - /// Generate repository resources YAML pub fn generate_repositories(repositories: &[Repository]) -> String { if repositories.is_empty() { @@ -459,14 +423,6 @@ pub fn validate_checkout_list(repositories: &[Repository], checkout: &[String]) Ok(()) } -/// Generate copilot CLI params from front matter configuration -pub fn generate_copilot_params( - front_matter: &FrontMatter, - extensions: &[super::extensions::Extension], -) -> Result { - crate::engine::GITHUB_COPILOT_CLI_ENGINE.generate_cli_params(front_matter, extensions) -} - /// Compute the effective workspace based on explicit setting and checkout configuration. pub fn compute_effective_workspace( explicit_workspace: &Option, @@ -910,13 +866,6 @@ pub fn generate_acquire_ado_token(service_connection: Option<&str>, variable_nam } } -/// Generate the env block entries for the copilot AWF step (Stage 1 agent). -/// Uses the read-only token from the read service connection. -/// When not configured, omits ADO access tokens entirely. -pub fn generate_copilot_ado_env(read_service_connection: Option<&str>) -> String { - crate::engine::GITHUB_COPILOT_CLI_ENGINE.generate_agent_ado_env(read_service_connection) -} - /// Generate the env block entries for the executor step (Stage 3 Execution). /// Uses the write token from the write service connection. /// When not configured, omits ADO access tokens entirely. @@ -1967,7 +1916,7 @@ pub async fn compile_shared( } // 4. Generate copilot params - let copilot_params = generate_copilot_params(front_matter, extensions)?; + let copilot_params = ctx.engine.args(ctx.front_matter, extensions)?; // 5. Compute workspace, working directory, triggers let effective_workspace = compute_effective_workspace( @@ -2015,12 +1964,7 @@ pub async fn compile_shared( .and_then(|p| p.read.as_deref()), "SC_READ_TOKEN", ); - let copilot_ado_env = generate_copilot_ado_env( - front_matter - .permissions - .as_ref() - .and_then(|p| p.read.as_deref()), - ); + let engine_env = ctx.engine.env(); let acquire_write_token = generate_acquire_ado_token( front_matter .permissions @@ -2101,7 +2045,7 @@ pub async fn compile_shared( ("{{ workspace }}", &working_directory), ("{{ agent_content }}", markdown_body), ("{{ acquire_ado_token }}", &acquire_read_token), - ("{{ copilot_ado_env }}", &copilot_ado_env), + ("{{ engine_env }}", &engine_env), ("{{ acquire_write_token }}", &acquire_write_token), ("{{ executor_ado_env }}", &executor_ado_env), ]; @@ -2212,7 +2156,7 @@ mod tests { assert!(result.is_ok()); } - // ─── generate_copilot_params ────────────────────────────────────────────── + // ─── Engine::args (copilot params) ────────────────────────────────────── #[test] fn test_copilot_params_bash_wildcard() { @@ -2223,7 +2167,7 @@ mod tests { cache_memory: None, azure_devops: None, }); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("--allow-all-tools"), "wildcard bash should emit --allow-all-tools"); assert!(!params.contains("--allow-tool"), "no individual --allow-tool flags with --allow-all-tools"); } @@ -2237,7 +2181,7 @@ mod tests { cache_memory: None, azure_devops: None, }); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("--allow-all-tools"), "\"*\" should behave same as \":*\""); assert!(!params.contains("--allow-tool"), "no individual --allow-tool flags with --allow-all-tools"); } @@ -2251,14 +2195,14 @@ mod tests { cache_memory: None, azure_devops: None, }); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("shell(")); } #[test] fn test_copilot_params_allow_all_paths_when_edit_enabled() { let fm = minimal_front_matter(); // edit defaults to true, bash defaults to wildcard - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("--allow-all-paths"), "edit enabled (default) should emit --allow-all-paths"); assert!(params.contains("--allow-all-tools"), "default (no bash) should emit --allow-all-tools"); assert!(!params.contains("--allow-tool"), "no individual --allow-tool flags with --allow-all-tools"); @@ -2273,7 +2217,7 @@ mod tests { cache_memory: None, azure_devops: None, }); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("--allow-all-paths"), "edit disabled should NOT emit --allow-all-paths"); assert!(!params.contains("--allow-tool write"), "edit disabled should NOT emit --allow-tool write"); } @@ -2287,7 +2231,7 @@ mod tests { cache_memory: None, azure_devops: None, }); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("--allow-all-tools"), "wildcard bash should emit --allow-all-tools"); assert!(params.contains("--allow-all-paths"), "edit enabled should still emit --allow-all-paths"); assert!(!params.contains("--allow-tool"), "no individual --allow-tool flags"); @@ -2305,7 +2249,7 @@ mod tests { fm.runtimes = Some(crate::compile::types::RuntimesConfig { lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)), }); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("shell(lean)"), "lean command should be allowed"); assert!(params.contains("shell(lake)"), "lake command should be allowed"); assert!(params.contains("shell(elan)"), "elan command should be allowed"); @@ -2325,7 +2269,7 @@ mod tests { fm.runtimes = Some(crate::compile::types::RuntimesConfig { lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)), }); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("--allow-all-tools"), "wildcard should use --allow-all-tools"); // Should NOT add individual tool flags when --allow-all-tools is active assert!(!params.contains("--allow-tool"), "no individual tool flags with --allow-all-tools"); @@ -2341,7 +2285,7 @@ mod tests { ..Default::default() }), ); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!( !params.contains("--allow-tool my-tool"), "default (all-tools) mode should not emit individual --allow-tool for MCPs" @@ -2364,7 +2308,7 @@ mod tests { ..Default::default() }), ); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("--allow-tool my-tool"), "container MCP should get --allow-tool"); } @@ -2384,7 +2328,7 @@ mod tests { ..Default::default() }), ); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("--allow-tool remote-ado"), "URL MCP should get --allow-tool"); } @@ -2395,7 +2339,7 @@ mod tests { "my-tool".to_string(), McpConfig::Enabled(true), ); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("--allow-tool my-tool"), "Enabled(true) with no container/url should not get --allow-tool"); } @@ -2422,7 +2366,7 @@ mod tests { ..Default::default() }), ); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); let a_pos = params.find("--allow-tool a-tool").expect("a-tool should be present"); let z_pos = params.find("--allow-tool z-tool").expect("z-tool should be present"); assert!(a_pos < z_pos, "MCPs should be sorted alphabetically: a-tool before z-tool"); @@ -2433,7 +2377,7 @@ mod tests { let mut fm = minimal_front_matter(); fm.mcp_servers .insert("ado".to_string(), McpConfig::Enabled(true)); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); // Copilot CLI has no built-in MCPs — all MCPs are handled via the MCP firewall assert!(!params.contains("--mcp ado")); } @@ -2444,14 +2388,14 @@ mod tests { "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 50\n---\n", ) .unwrap(); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg"); } #[test] fn test_copilot_params_no_max_turns_when_simple_engine() { let fm = minimal_front_matter(); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("--max-turns")); } @@ -2461,14 +2405,14 @@ mod tests { "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 30\n---\n", ) .unwrap(); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg"); } #[test] fn test_copilot_params_no_max_timeout_when_simple_engine() { let fm = minimal_front_matter(); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("--max-timeout")); } @@ -2478,7 +2422,7 @@ mod tests { "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n max-turns: 0\n---\n", ) .unwrap(); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("--max-turns"), "max-turns should not be emitted as a CLI arg"); } @@ -2488,7 +2432,7 @@ mod tests { "---\nname: test\ndescription: test\nengine:\n model: claude-opus-4.5\n timeout-minutes: 0\n---\n", ) .unwrap(); - let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(!params.contains("--max-timeout"), "timeout-minutes should not be emitted as a CLI arg"); } @@ -2700,37 +2644,6 @@ mod tests { assert!(result.contains("pipeline: my_build_pipeline")); } - // ─── generate_cancel_previous_builds ───────────────────────────────────── - - #[test] - fn test_generate_cancel_previous_builds_no_triggers() { - let result = generate_cancel_previous_builds(&None); - assert!(result.is_empty()); - } - - #[test] - fn test_generate_cancel_previous_builds_no_pipeline_trigger() { - let triggers = Some(crate::compile::types::TriggerConfig { pipeline: None }); - let result = generate_cancel_previous_builds(&triggers); - assert!(result.is_empty()); - } - - #[test] - fn test_generate_cancel_previous_builds_with_pipeline_trigger() { - let triggers = Some(crate::compile::types::TriggerConfig { - pipeline: Some(crate::compile::types::PipelineTrigger { - name: "Build".into(), - project: None, - branches: vec![], - }), - }); - let result = generate_cancel_previous_builds(&triggers); - assert!(!result.is_empty()); - assert!(result.contains("Cancel previous queued builds")); - assert!(result.contains("SYSTEM_ACCESSTOKEN")); - assert!(result.contains("cancelling")); - } - // ─── generate_header_comment ──────────────────────────────────────────── #[test] @@ -3420,26 +3333,20 @@ mod tests { assert!(!result.contains("SC_READ_TOKEN")); } - // ─── generate_copilot_ado_env / generate_executor_ado_env ──────────────── + // ─── engine env / generate_executor_ado_env ──────────────────────────── #[test] - fn test_generate_copilot_ado_env_with_connection() { - let result = generate_copilot_ado_env(Some("my-sc")); + fn test_engine_env() { + let fm = minimal_front_matter(); + let ctx = CompileContext::for_test(&fm); + let result = ctx.engine.env(); assert!( - result.contains("AZURE_DEVOPS_EXT_PAT: $(SC_READ_TOKEN)"), - "Should set AZURE_DEVOPS_EXT_PAT to SC_READ_TOKEN" + result.contains("GITHUB_TOKEN: $(GITHUB_TOKEN)"), + "Should include GITHUB_TOKEN" ); assert!( - result.contains("SYSTEM_ACCESSTOKEN: $(SC_READ_TOKEN)"), - "Should set SYSTEM_ACCESSTOKEN to SC_READ_TOKEN" - ); - } - - #[test] - fn test_generate_copilot_ado_env_none_empty() { - assert!( - generate_copilot_ado_env(None).is_empty(), - "None service connection should produce empty env block" + !result.contains("AZURE_DEVOPS_EXT_PAT"), + "ADO token is handled by MCPG, not engine env" ); } @@ -3469,12 +3376,15 @@ mod tests { #[test] fn test_model_name_rejects_single_quote() { - let (mut fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nengine: claude-opus-4.5\n---\n", - ) - .unwrap(); - fm.engine = crate::compile::types::EngineConfig::Simple("model' && echo pwned".to_string()); - let result = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)); + let mut fm = minimal_front_matter(); + fm.engine = crate::compile::types::EngineConfig::Full(crate::compile::types::EngineOptions { + id: Some("copilot".to_string()), + model: Some("model' && echo pwned".to_string()), + version: None, agent: None, api_target: None, + args: vec![], env: None, command: None, + max_turns: None, timeout_minutes: None, + }); + let result = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("invalid characters")); } @@ -3482,8 +3392,14 @@ mod tests { #[test] fn test_model_name_rejects_space() { let mut fm = minimal_front_matter(); - fm.engine = crate::compile::types::EngineConfig::Simple("model && curl evil.com".to_string()); - let result = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)); + fm.engine = crate::compile::types::EngineConfig::Full(crate::compile::types::EngineOptions { + id: Some("copilot".to_string()), + model: Some("model && curl evil.com".to_string()), + version: None, agent: None, api_target: None, + args: vec![], env: None, command: None, + max_turns: None, timeout_minutes: None, + }); + let result = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)); assert!(result.is_err()); } @@ -3491,8 +3407,14 @@ mod tests { fn test_model_name_allows_valid_names() { for name in &["claude-opus-4.5", "gpt-5.2-codex", "gemini-3-pro-preview", "my_model:latest"] { let mut fm = minimal_front_matter(); - fm.engine = crate::compile::types::EngineConfig::Simple(name.to_string()); - let result = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)); + fm.engine = crate::compile::types::EngineConfig::Full(crate::compile::types::EngineOptions { + id: Some("copilot".to_string()), + model: Some(name.to_string()), + version: None, agent: None, api_target: None, + args: vec![], env: None, command: None, + max_turns: None, timeout_minutes: None, + }); + let result = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)); assert!(result.is_ok(), "Model name '{}' should be valid", name); } } @@ -3506,7 +3428,7 @@ mod tests { cache_memory: None, azure_devops: None, }); - let result = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)); + let result = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("single quote")); } diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 124e0b5..46d612e 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -83,6 +83,7 @@ pub struct McpgConfig { // ────────────────────────────────────────────────────────────────────── use crate::configure::AdoContext; +use crate::engine::{self, Engine}; use std::path::Path; /// Metadata resolved at compile time from the local environment. @@ -99,20 +100,25 @@ pub struct CompileContext<'a> { /// ADO context inferred from the git remote (org URL, project, repo name). /// `None` if the compile directory has no ADO remote. pub ado_context: Option, + /// Resolved engine based on the front matter `engine:` field. + pub engine: Engine, } impl<'a> CompileContext<'a> { /// Build a fully-resolved compile context. /// - /// Infers ADO context from the git remote in `compile_dir`. This is - /// async because it shells out to `git remote get-url origin`. - pub async fn new(front_matter: &'a FrontMatter, compile_dir: &Path) -> Self { + /// Resolves the engine implementation from front matter and infers ADO + /// context from the git remote in `compile_dir`. Returns an error if + /// the engine identifier is unsupported. + pub async fn new(front_matter: &'a FrontMatter, compile_dir: &Path) -> Result { + let engine = engine::get_engine(front_matter.engine.engine_id())?; let ado_context = Self::infer_ado_context(compile_dir).await; - Self { + Ok(Self { agent_name: &front_matter.name, front_matter, ado_context, - } + engine, + }) } /// Convenience accessor: extract the ADO org name from the inferred context. @@ -156,6 +162,7 @@ impl<'a> CompileContext<'a> { agent_name: &front_matter.name, front_matter, ado_context: None, + engine: crate::engine::Engine::Copilot, } } @@ -170,6 +177,7 @@ impl<'a> CompileContext<'a> { project: "test-project".to_string(), repo_name: "test-repo".to_string(), }), + engine: crate::engine::Engine::Copilot, } } } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 499bc0a..d3e7997 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -19,7 +19,6 @@ use std::path::{Path, PathBuf}; pub use common::parse_markdown; pub use common::HEADER_MARKER; -pub use common::generate_copilot_params; pub use common::generate_mcpg_config; pub use common::MCPG_IMAGE; pub use common::MCPG_VERSION; @@ -76,7 +75,7 @@ pub async fn compile_pipeline( info!("Parsed agent: '{}'", front_matter.name); debug!("Description: {}", front_matter.description); debug!("Target: {:?}", front_matter.target); - debug!("Engine model: {}", front_matter.engine.model()); + debug!("Engine: {} (model: {})", front_matter.engine.engine_id(), front_matter.engine.model().unwrap_or("default")); debug!("Schedule: {:?}", front_matter.schedule); debug!("Repositories: {}", front_matter.repositories.len()); debug!("MCP servers configured: {}", front_matter.mcp_servers.len()); diff --git a/src/compile/onees.rs b/src/compile/onees.rs index add78a1..aa89876 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -14,7 +14,6 @@ use super::common::{ AWF_VERSION, MCPG_VERSION, MCPG_IMAGE, MCPG_PORT, MCPG_DOMAIN, CompileConfig, compile_shared, generate_allowed_domains, - generate_cancel_previous_builds, generate_enabled_tools_args, generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env, format_steps_yaml_indented, @@ -46,12 +45,11 @@ impl Compiler for OneESCompiler { // Build compile context for MCPG config generation let input_dir = input_path.parent().unwrap_or(Path::new(".")); - let ctx = super::extensions::CompileContext::new(front_matter, input_dir).await; + let ctx = super::extensions::CompileContext::new(front_matter, input_dir).await?; // Generate values shared with standalone that are passed as extra replacements let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; let enabled_tools_args = generate_enabled_tools_args(front_matter); - let cancel_previous_builds = generate_cancel_previous_builds(&front_matter.triggers); let mcpg_config = generate_mcpg_config(front_matter, &ctx, &extensions)?; let mcpg_config_json = serde_json::to_string_pretty(&mcpg_config) @@ -75,7 +73,6 @@ impl Compiler for OneESCompiler { ("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()), ("{{ allowed_domains }}".into(), allowed_domains), ("{{ enabled_tools_args }}".into(), enabled_tools_args), - ("{{ cancel_previous_builds }}".into(), cancel_previous_builds), ("{{ mcpg_config }}".into(), mcpg_config_json), ("{{ mcpg_docker_env }}".into(), mcpg_docker_env), ("{{ mcpg_step_env }}".into(), mcpg_step_env), diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index aa994aa..2e4c5e8 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -16,7 +16,6 @@ use super::common::{ AWF_VERSION, MCPG_VERSION, MCPG_IMAGE, MCPG_PORT, MCPG_DOMAIN, CompileConfig, compile_shared, generate_allowed_domains, - generate_cancel_previous_builds, generate_enabled_tools_args, generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env, }; @@ -47,12 +46,11 @@ impl Compiler for StandaloneCompiler { // Build compile context for MCPG config generation let input_dir = input_path.parent().unwrap_or(std::path::Path::new(".")); - let ctx = super::extensions::CompileContext::new(front_matter, input_dir).await; + let ctx = super::extensions::CompileContext::new(front_matter, input_dir).await?; // Standalone-specific values let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; let enabled_tools_args = generate_enabled_tools_args(front_matter); - let cancel_previous_builds = generate_cancel_previous_builds(&front_matter.triggers); let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?; let mcpg_config_json = @@ -70,7 +68,6 @@ impl Compiler for StandaloneCompiler { ("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()), ("{{ allowed_domains }}".into(), allowed_domains), ("{{ enabled_tools_args }}".into(), enabled_tools_args), - ("{{ cancel_previous_builds }}".into(), cancel_previous_builds), ("{{ mcpg_config }}".into(), mcpg_config_json), ("{{ mcpg_docker_env }}".into(), mcpg_docker_env), ("{{ mcpg_step_env }}".into(), mcpg_step_env), diff --git a/src/compile/types.rs b/src/compile/types.rs index d7a3039..da679ca 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -140,22 +140,36 @@ pub struct ScheduleOptions { pub branches: Vec, } -/// Engine configuration - accepts both string and object formats +/// Engine configuration — aligned with gh-aw's engine front matter. +/// +/// The string form is an engine identifier (e.g., `copilot`). The object form +/// uses `id` for the engine identifier plus additional options. +/// +/// Currently only `copilot` (GitHub Copilot CLI) is supported. Other engine +/// identifiers produce a compile error. /// /// Examples: /// ```yaml -/// # Simple string format (just a model name) -/// engine: claude-opus-4.5 +/// # Simple string format (engine identifier, defaults to copilot) +/// engine: copilot /// /// # Object format (with additional options) /// engine: +/// id: copilot /// model: claude-opus-4.5 /// timeout-minutes: 30 +/// version: latest +/// agent: my-custom-agent +/// api-target: api.acme.ghe.com +/// args: ["--verbose"] +/// env: +/// DEBUG_MODE: "true" +/// command: /usr/local/bin/copilot /// ``` #[derive(Debug, Deserialize, Clone)] #[serde(untagged)] pub enum EngineConfig { - /// Simple model name string + /// Engine identifier string (e.g., "copilot") Simple(String), /// Full engine configuration object Full(EngineOptions), @@ -163,16 +177,25 @@ pub enum EngineConfig { impl Default for EngineConfig { fn default() -> Self { - EngineConfig::Simple(default_model()) + EngineConfig::Simple("copilot".to_string()) } } impl EngineConfig { - /// Get the model name - pub fn model(&self) -> &str { + /// Get the engine identifier (e.g., "copilot"). + pub fn engine_id(&self) -> &str { match self { EngineConfig::Simple(s) => s, - EngineConfig::Full(opts) => opts.model.as_deref().unwrap_or("claude-opus-4.5"), + EngineConfig::Full(opts) => opts.id.as_deref().unwrap_or("copilot"), + } + } + + /// Get the model name override, if specified. + /// Returns `None` when the engine should use its default model. + pub fn model(&self) -> Option<&str> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.model.as_deref(), } } @@ -191,6 +214,54 @@ impl EngineConfig { EngineConfig::Full(opts) => opts.timeout_minutes, } } + + /// Get the engine version override (e.g., "0.0.422", "latest") + pub fn version(&self) -> Option<&str> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.version.as_deref(), + } + } + + /// Get the custom agent file identifier (Copilot only, e.g., "my-agent") + pub fn agent(&self) -> Option<&str> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.agent.as_deref(), + } + } + + /// Get the custom API endpoint hostname (GHEC/GHES) + pub fn api_target(&self) -> Option<&str> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.api_target.as_deref(), + } + } + + /// Get custom CLI arguments + pub fn args(&self) -> &[String] { + match self { + EngineConfig::Simple(_) => &[], + EngineConfig::Full(opts) => &opts.args, + } + } + + /// Get custom environment variables + pub fn env(&self) -> Option<&HashMap> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.env.as_ref(), + } + } + + /// Get custom engine command path + pub fn command(&self) -> Option<&str> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.command.as_deref(), + } + } } impl SanitizeConfigTrait for EngineConfig { @@ -204,9 +275,30 @@ impl SanitizeConfigTrait for EngineConfig { #[derive(Debug, Deserialize, Clone, SanitizeConfig)] pub struct EngineOptions { - /// AI model to use (defaults to claude-opus-4.5) + /// Engine identifier (e.g., "copilot"). Defaults to "copilot" when omitted. + #[serde(default)] + pub id: Option, + /// AI model to use (engine-specific default when omitted) #[serde(default)] pub model: Option, + /// Engine CLI version to install (e.g., "0.0.422", "latest") + #[serde(default)] + pub version: Option, + /// Custom agent file identifier (Copilot only — references .github/agents/) + #[serde(default)] + pub agent: Option, + /// Custom API endpoint hostname (GHEC/GHES, e.g., "api.acme.ghe.com") + #[serde(default, rename = "api-target")] + pub api_target: Option, + /// Custom CLI arguments injected before the prompt + #[serde(default)] + pub args: Vec, + /// Engine-specific environment variables + #[serde(default)] + pub env: Option>, + /// Custom engine executable path (skips default installation) + #[serde(default)] + pub command: Option, /// Maximum number of chat iterations per run (deprecated — not supported by Copilot CLI) #[serde(default, rename = "max-turns")] pub max_turns: Option, @@ -499,7 +591,7 @@ pub struct FrontMatter { /// Agent pool configuration #[serde(default)] pub pool: Option, - /// AI engine configuration (defaults to claude-opus-4.5) + /// AI engine configuration (defaults to copilot) #[serde(default)] pub engine: EngineConfig, /// Tools configuration @@ -602,10 +694,6 @@ impl SanitizeConfigTrait for FrontMatter { } } -fn default_model() -> String { - "claude-opus-4.5".to_string() -} - /// Network policy configuration (standalone target only) /// /// Network isolation uses AWF (Agentic Workflow Firewall) for L7 domain whitelisting. @@ -845,18 +933,20 @@ mod tests { #[test] fn test_engine_config_simple_string() { - let ec = EngineConfig::Simple("gpt-5.1".to_string()); - assert_eq!(ec.model(), "gpt-5.1"); + let ec = EngineConfig::Simple("copilot".to_string()); + assert_eq!(ec.engine_id(), "copilot"); + assert_eq!(ec.model(), None); assert_eq!(ec.max_turns(), None); assert_eq!(ec.timeout_minutes(), None); } #[test] fn test_engine_config_full_object() { - let yaml = "model: claude-sonnet-4.5\nmax-turns: 50\ntimeout-minutes: 30"; + let yaml = "id: copilot\nmodel: claude-sonnet-4.5\nmax-turns: 50\ntimeout-minutes: 30"; let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); let ec = EngineConfig::Full(opts); - assert_eq!(ec.model(), "claude-sonnet-4.5"); + assert_eq!(ec.engine_id(), "copilot"); + assert_eq!(ec.model(), Some("claude-sonnet-4.5")); assert_eq!(ec.max_turns(), Some(50)); assert_eq!(ec.timeout_minutes(), Some(30)); } @@ -866,8 +956,10 @@ mod tests { let yaml = "max-turns: 10"; let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); let ec = EngineConfig::Full(opts); - // model defaults to claude-opus-4.5 when not specified - assert_eq!(ec.model(), "claude-opus-4.5"); + // id defaults to "copilot" when not specified + assert_eq!(ec.engine_id(), "copilot"); + // model is None when not specified (engine impl decides default) + assert_eq!(ec.model(), None); assert_eq!(ec.max_turns(), Some(10)); assert_eq!(ec.timeout_minutes(), None); } @@ -875,17 +967,19 @@ mod tests { #[test] fn test_engine_config_default() { let ec = EngineConfig::default(); - assert_eq!(ec.model(), "claude-opus-4.5"); + assert_eq!(ec.engine_id(), "copilot"); + assert_eq!(ec.model(), None); assert_eq!(ec.max_turns(), None); assert_eq!(ec.timeout_minutes(), None); } #[test] fn test_engine_config_deserialized_as_string() { - let yaml = "engine: my-custom-model"; + let yaml = "engine: copilot"; let fm: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let ec: EngineConfig = serde_yaml::from_value(fm["engine"].clone()).unwrap(); - assert_eq!(ec.model(), "my-custom-model"); + assert_eq!(ec.engine_id(), "copilot"); + assert_eq!(ec.model(), None); assert_eq!(ec.max_turns(), None); assert_eq!(ec.timeout_minutes(), None); } @@ -893,14 +987,54 @@ mod tests { #[test] fn test_engine_config_deserialized_as_object() { let yaml = - "engine:\n model: claude-opus-4.5\n max-turns: 50\n timeout-minutes: 30"; + "engine:\n id: copilot\n model: claude-opus-4.5\n max-turns: 50\n timeout-minutes: 30"; let fm: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let ec: EngineConfig = serde_yaml::from_value(fm["engine"].clone()).unwrap(); - assert_eq!(ec.model(), "claude-opus-4.5"); + assert_eq!(ec.engine_id(), "copilot"); + assert_eq!(ec.model(), Some("claude-opus-4.5")); assert_eq!(ec.max_turns(), Some(50)); assert_eq!(ec.timeout_minutes(), Some(30)); } + #[test] + fn test_engine_config_full_with_all_gh_aw_fields() { + let yaml = r#" +id: copilot +model: gpt-5 +version: "0.0.422" +agent: my-custom-agent +api-target: api.acme.ghe.com +args: ["--verbose", "--add-dir", "/workspace"] +env: + DEBUG_MODE: "true" + AWS_REGION: us-west-2 +command: /usr/local/bin/copilot +timeout-minutes: 60 +"#; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let ec = EngineConfig::Full(opts); + assert_eq!(ec.engine_id(), "copilot"); + assert_eq!(ec.model(), Some("gpt-5")); + assert_eq!(ec.version(), Some("0.0.422")); + assert_eq!(ec.agent(), Some("my-custom-agent")); + assert_eq!(ec.api_target(), Some("api.acme.ghe.com")); + assert_eq!(ec.args(), &["--verbose", "--add-dir", "/workspace"]); + assert_eq!(ec.command(), Some("/usr/local/bin/copilot")); + assert_eq!(ec.timeout_minutes(), Some(60)); + let env = ec.env().unwrap(); + assert_eq!(env.get("DEBUG_MODE").unwrap(), "true"); + assert_eq!(env.get("AWS_REGION").unwrap(), "us-west-2"); + } + + #[test] + fn test_engine_config_id_defaults_to_copilot() { + let yaml = "model: gpt-5\ntimeout-minutes: 30"; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let ec = EngineConfig::Full(opts); + assert_eq!(ec.engine_id(), "copilot"); + assert_eq!(ec.model(), Some("gpt-5")); + } + // ─── PermissionsConfig deserialization ─────────────────────────────── #[test] diff --git a/src/data/1es-base.yml b/src/data/1es-base.yml index 4239d89..ab28da2 100644 --- a/src/data/1es-base.yml +++ b/src/data/1es-base.yml @@ -56,8 +56,6 @@ extends: {{ acquire_ado_token }} - {{ cancel_previous_builds }} - - task: NuGetAuthenticate@1 displayName: "Authenticate NuGet Feed" @@ -386,12 +384,7 @@ extends: displayName: "Run copilot (AWF network isolated)" workingDirectory: {{ working_directory }} env: - {{ copilot_ado_env }} - GITHUB_TOKEN: $(GITHUB_TOKEN) - GITHUB_READ_ONLY: 1 - COPILOT_OTEL_ENABLED: "true" - COPILOT_OTEL_EXPORTER_TYPE: "file" - COPILOT_OTEL_FILE_EXPORTER_PATH: "/tmp/awf-tools/staging/otel.jsonl" + {{ engine_env }} - bash: | # Copy safe outputs from /tmp back to staging for artifact publish diff --git a/src/data/base.yml b/src/data/base.yml index ec51af2..e101836 100644 --- a/src/data/base.yml +++ b/src/data/base.yml @@ -27,8 +27,6 @@ jobs: {{ acquire_ado_token }} - {{ cancel_previous_builds }} - - task: NuGetAuthenticate@1 displayName: "Authenticate NuGet Feed" @@ -357,12 +355,7 @@ jobs: displayName: "Run copilot (AWF network isolated)" workingDirectory: {{ working_directory }} env: - {{ copilot_ado_env }} - GITHUB_TOKEN: $(GITHUB_TOKEN) - GITHUB_READ_ONLY: 1 - COPILOT_OTEL_ENABLED: "true" - COPILOT_OTEL_EXPORTER_TYPE: "file" - COPILOT_OTEL_FILE_EXPORTER_PATH: "/tmp/awf-tools/staging/otel.jsonl" + {{ engine_env }} - bash: | # Copy safe outputs from /tmp back to staging for artifact publish diff --git a/src/engine.rs b/src/engine.rs index 63b85ef..81234ae 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -3,233 +3,364 @@ use anyhow::Result; use crate::compile::extensions::{CompilerExtension, Extension}; use crate::compile::types::{FrontMatter, McpConfig}; -pub trait Engine { - fn generate_cli_params( - &self, - front_matter: &FrontMatter, - extensions: &[Extension], - ) -> Result; +/// Default model used by the Copilot engine when no model is specified in front matter. +pub const DEFAULT_COPILOT_MODEL: &str = "claude-opus-4.5"; - fn generate_agent_ado_env(&self, read_service_connection: Option<&str>) -> String; +/// Resolved engine — enum dispatch over supported engine identifiers. +/// +/// Currently only `Copilot` (GitHub Copilot CLI) is supported. New engines +/// are added as variants here rather than via trait objects. +#[derive(Debug, Clone, Copy)] +pub enum Engine { + Copilot, } -pub struct GitHubCopilotCliEngine; +/// Resolve the engine for a given engine identifier from front matter. +/// +/// Currently only `copilot` is supported. Other identifiers produce a +/// compile error to prevent misconfiguration. +pub fn get_engine(engine_id: &str) -> Result { + match engine_id { + "copilot" => Ok(Engine::Copilot), + other => anyhow::bail!( + "Unsupported engine '{}'. Only 'copilot' is supported by ado-aw. \ + See gh-aw documentation for engine identifiers.", + other + ), + } +} -pub const GITHUB_COPILOT_CLI_ENGINE: GitHubCopilotCliEngine = GitHubCopilotCliEngine; +impl Engine { + /// The default engine binary name (e.g., "copilot"). + /// + /// Currently scaffolding — the pipeline templates hard-code the binary path + /// (`/tmp/awf-tools/copilot`). This will be wired into template substitution + /// when additional engines are added. Can be overridden per-agent via + /// `engine.command` in front matter. + #[allow(dead_code)] + pub fn command(&self) -> &str { + match self { + Engine::Copilot => "copilot", + } + } -impl Engine for GitHubCopilotCliEngine { - fn generate_cli_params( + /// Generate CLI arguments for the engine invocation. + pub fn args( &self, front_matter: &FrontMatter, extensions: &[Extension], ) -> Result { - // Check if bash triggers --allow-all-tools. This happens when: - // 1. Bash has an explicit wildcard entry (":*" or "*"), OR - // 2. Bash is not specified at all (None) — ado-aw agents always run in AWF sandbox, - // and gh-aw defaults to bash: ["*"] when sandbox is enabled (applyDefaultTools). - // - // Note: wildcard detection requires exactly one entry (cmds.len() == 1). Mixing a - // wildcard with other commands (e.g. bash: [":*", "cat"]) is not supported and will - // fall through to the restricted path, emitting "shell(:*)" literally. - let bash_config = front_matter.tools.as_ref().and_then(|t| t.bash.as_ref()); - let use_allow_all_tools = match bash_config { - Some(cmds) if cmds.len() == 1 && (cmds[0] == ":*" || cmds[0] == "*") => true, - None => true, // default: all tools (matches gh-aw sandbox default) - _ => false, - }; - - // Edit tool: enabled by default, can be disabled with `edit: false` - let edit_enabled = front_matter - .tools - .as_ref() - .and_then(|t| t.edit) - .unwrap_or(true); - - // When --allow-all-tools is active, skip individual tool collection entirely. - // --allow-all-tools is a superset that permits all tool calls regardless. - let mut allowed_tools: Vec = Vec::new(); - - if !use_allow_all_tools { - // Collect tool permissions from extensions (github, safeoutputs, azure-devops, etc.) - for ext in extensions { - for tool in ext.allowed_copilot_tools() { - if !allowed_tools.contains(&tool) { - allowed_tools.push(tool); - } + match self { + Engine::Copilot => copilot_args(front_matter, extensions), + } + } + + /// Generate the env block entries for the engine's sandbox step. + pub fn env(&self) -> String { + match self { + Engine::Copilot => copilot_env(), + } + } +} + +fn copilot_args( + front_matter: &FrontMatter, + extensions: &[Extension], +) -> Result { + // Check if bash triggers --allow-all-tools. This happens when: + // 1. Bash has an explicit wildcard entry (":*" or "*"), OR + // 2. Bash is not specified at all (None) — ado-aw agents always run in AWF sandbox, + // and gh-aw defaults to bash: ["*"] when sandbox is enabled (applyDefaultTools). + // + // Note: wildcard detection requires exactly one entry (cmds.len() == 1). Mixing a + // wildcard with other commands (e.g. bash: [":*", "cat"]) is not supported and will + // fall through to the restricted path, emitting "shell(:*)" literally. + let bash_config = front_matter.tools.as_ref().and_then(|t| t.bash.as_ref()); + let use_allow_all_tools = match bash_config { + Some(cmds) if cmds.len() == 1 && (cmds[0] == ":*" || cmds[0] == "*") => true, + None => true, // default: all tools (matches gh-aw sandbox default) + _ => false, + }; + + // Edit tool: enabled by default, can be disabled with `edit: false` + let edit_enabled = front_matter + .tools + .as_ref() + .and_then(|t| t.edit) + .unwrap_or(true); + + // When --allow-all-tools is active, skip individual tool collection entirely. + // --allow-all-tools is a superset that permits all tool calls regardless. + let mut allowed_tools: Vec = Vec::new(); + + if !use_allow_all_tools { + // Collect tool permissions from extensions (github, safeoutputs, azure-devops, etc.) + for ext in extensions { + for tool in ext.allowed_copilot_tools() { + if !allowed_tools.contains(&tool) { + allowed_tools.push(tool); } } + } - // Collect tool permissions from user-defined MCP servers (sorted for deterministic output). - // Only add --allow-tool for MCPs that will actually produce an MCPG entry (i.e., - // WithOptions that have a container or url). McpConfig::Enabled(true) has no backing - // server in MCPG, so granting the permission would cause confusing runtime errors. - let mut sorted_mcps: Vec<_> = front_matter.mcp_servers.iter().collect(); - sorted_mcps.sort_by(|(a, _), (b, _)| a.cmp(b)); - for (name, config) in sorted_mcps { - // Skip servers already provided by extensions (case-insensitive to match - // generate_mcpg_config's eq_ignore_ascii_case guard for reserved names) - if allowed_tools.iter().any(|t| t.eq_ignore_ascii_case(name)) { - continue; - } - // Only add MCPs that have a backing server (container or url) - let has_backing_server = match config { - McpConfig::Enabled(_) => false, - McpConfig::WithOptions(opts) => { - opts.enabled.unwrap_or(true) - && (opts.container.is_some() || opts.url.is_some()) - } - }; - if !has_backing_server { - continue; + // Collect tool permissions from user-defined MCP servers (sorted for deterministic output). + // Only add --allow-tool for MCPs that will actually produce an MCPG entry (i.e., + // WithOptions that have a container or url). McpConfig::Enabled(true) has no backing + // server in MCPG, so granting the permission would cause confusing runtime errors. + let mut sorted_mcps: Vec<_> = front_matter.mcp_servers.iter().collect(); + sorted_mcps.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (name, config) in sorted_mcps { + // Skip servers already provided by extensions (case-insensitive to match + // generate_mcpg_config's eq_ignore_ascii_case guard for reserved names) + if allowed_tools.iter().any(|t| t.eq_ignore_ascii_case(name)) { + continue; + } + // Only add MCPs that have a backing server (container or url) + let has_backing_server = match config { + McpConfig::Enabled(_) => false, + McpConfig::WithOptions(opts) => { + opts.enabled.unwrap_or(true) + && (opts.container.is_some() || opts.url.is_some()) } - allowed_tools.push(name.clone()); + }; + if !has_backing_server { + continue; } + allowed_tools.push(name.clone()); + } - // Intentional: with restricted bash, both --allow-tool write (tool identity) - // and --allow-all-paths (path scope) are emitted. --allow-all-tools subsumes - // --allow-tool write, so only --allow-all-paths is needed on that path. - if edit_enabled { - allowed_tools.push("write".to_string()); - } + // Intentional: with restricted bash, both --allow-tool write (tool identity) + // and --allow-all-paths (path scope) are emitted. --allow-all-tools subsumes + // --allow-tool write, so only --allow-all-paths is needed on that path. + if edit_enabled { + allowed_tools.push("write".to_string()); + } - // Bash tool: use the explicitly configured list. - // When bash is None (not specified), use_allow_all_tools is true and this - // block is skipped entirely (gh-aw sandbox default = wildcard). - let mut bash_commands: Vec = - match front_matter.tools.as_ref().and_then(|t| t.bash.as_ref()) { - Some(cmds) if cmds.is_empty() => { - // Explicitly disabled: no bash commands - vec![] - } - Some(cmds) => { - // Explicit list of commands - cmds.clone() - } - None => { - // Invariant: bash=None → use_allow_all_tools=true → this block is - // skipped. Panic if the invariant is ever broken. - unreachable!("bash=None should imply use_allow_all_tools=true") - } - }; - - // Auto-add extension-declared bash commands (runtimes + first-party tools) - for ext in extensions { - for cmd in ext.required_bash_commands() { - if !bash_commands.contains(&cmd) { - bash_commands.push(cmd); - } + // Bash tool: use the explicitly configured list. + // When bash is None (not specified), use_allow_all_tools is true and this + // block is skipped entirely (gh-aw sandbox default = wildcard). + let mut bash_commands: Vec = + match front_matter.tools.as_ref().and_then(|t| t.bash.as_ref()) { + Some(cmds) if cmds.is_empty() => { + // Explicitly disabled: no bash commands + vec![] } - } + Some(cmds) => { + // Explicit list of commands + cmds.clone() + } + None => { + // Invariant: bash=None → use_allow_all_tools=true → this block is + // skipped. Panic if the invariant is ever broken. + unreachable!("bash=None should imply use_allow_all_tools=true") + } + }; - for cmd in &bash_commands { - // Reject single quotes in bash commands — copilot_params are embedded inside - // a single-quoted bash string in the AWF command. - if cmd.contains('\'') { - anyhow::bail!( - "Bash command '{}' contains a single quote, which is not allowed \ - (would break AWF shell quoting).", - cmd - ); + // Auto-add extension-declared bash commands (runtimes + first-party tools) + for ext in extensions { + for cmd in ext.required_bash_commands() { + if !bash_commands.contains(&cmd) { + bash_commands.push(cmd); } - allowed_tools.push(format!("shell({})", cmd)); } } - let mut params = Vec::new(); - - // Validate model name to prevent shell injection — copilot_params are embedded - // inside a single-quoted bash string in the AWF command. - let model = front_matter.engine.model(); - if model.is_empty() - || !model - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) - { - anyhow::bail!( - "Model name '{}' contains invalid characters. \ - Only ASCII alphanumerics, '.', '_', ':', and '-' are allowed.", - model - ); - } - params.push(format!("--model {}", model)); - if front_matter.engine.max_turns().is_some() { - eprintln!( - "Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \ - and will be ignored. Consider removing it from the engine configuration.", - front_matter.name - ); - } - if let Some(timeout_minutes) = front_matter.engine.timeout_minutes() { - if timeout_minutes == 0 { - eprintln!( - "Warning: Agent '{}' has timeout-minutes: 0, which means no time is allowed. \ - The agent job will time out immediately. \ - Consider setting timeout-minutes to at least 1.", - front_matter.name + for cmd in &bash_commands { + // Reject single quotes in bash commands — copilot_params are embedded inside + // a single-quoted bash string in the AWF command. + if cmd.contains('\'') { + anyhow::bail!( + "Bash command '{}' contains a single quote, which is not allowed \ + (would break AWF shell quoting).", + cmd ); } + allowed_tools.push(format!("shell({})", cmd)); } - params.push("--disable-builtin-mcps".to_string()); - params.push("--no-ask-user".to_string()); - - if use_allow_all_tools { - params.push("--allow-all-tools".to_string()); - } else { - for tool in allowed_tools { - if tool.contains('(') || tool.contains(')') || tool.contains(' ') { - // Use double quotes - the copilot_params are embedded inside a single-quoted - // bash string in the AWF command, so single quotes would break quoting. - params.push(format!("--allow-tool \"{}\"", tool)); - } else { - params.push(format!("--allow-tool {}", tool)); - } - } - } + } - // --allow-all-paths when edit is enabled — lets the agent write to any file path. - // Emitted independently of --allow-all-tools (matches gh-aw behavior). - if edit_enabled { - params.push("--allow-all-paths".to_string()); + let mut params = Vec::new(); + + // Validate model name to prevent shell injection — copilot_params are embedded + // inside a single-quoted bash string in the AWF command. + let model = front_matter.engine.model().unwrap_or(DEFAULT_COPILOT_MODEL); + if model.is_empty() + || !model + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) + { + anyhow::bail!( + "Model name '{}' contains invalid characters. \ + Only ASCII alphanumerics, '.', '_', ':', and '-' are allowed.", + model + ); + } + params.push(format!("--model {}", model)); + if front_matter.engine.max_turns().is_some() { + eprintln!( + "Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \ + and will be ignored. Consider removing it from the engine configuration.", + front_matter.name + ); + } + if let Some(timeout_minutes) = front_matter.engine.timeout_minutes() { + if timeout_minutes == 0 { + eprintln!( + "Warning: Agent '{}' has timeout-minutes: 0, which means no time is allowed. \ + The agent job will time out immediately. \ + Consider setting timeout-minutes to at least 1.", + front_matter.name + ); } + } - Ok(params.join(" ")) + // Warn about engine options that are parsed but not yet wired into the pipeline. + // These fields are scaffolding for future engines/features — users should know + // they have no effect today so they aren't confused by silent no-ops. + if !front_matter.engine.args().is_empty() { + eprintln!( + "Warning: Agent '{}' has engine.args set, but custom CLI arguments are not yet \ + wired into the pipeline and will be ignored.", + front_matter.name + ); + } + if front_matter.engine.version().is_some() { + eprintln!( + "Warning: Agent '{}' has engine.version set, but custom engine versioning is not yet \ + wired into the pipeline and will be ignored.", + front_matter.name + ); + } + if front_matter.engine.agent().is_some() { + eprintln!( + "Warning: Agent '{}' has engine.agent set, but custom agent file selection is not yet \ + wired into the pipeline and will be ignored.", + front_matter.name + ); + } + if front_matter.engine.api_target().is_some() { + eprintln!( + "Warning: Agent '{}' has engine.api-target set, but custom API target (GHES/GHEC) is \ + not yet wired into the pipeline and will be ignored.", + front_matter.name + ); + } + if front_matter.engine.command().is_some() { + eprintln!( + "Warning: Agent '{}' has engine.command set, but custom engine command paths are not \ + yet wired into the pipeline and will be ignored.", + front_matter.name + ); + } + if front_matter.engine.env().is_some() { + eprintln!( + "Warning: Agent '{}' has engine.env set, but custom engine environment variables are \ + not yet wired into the pipeline and will be ignored.", + front_matter.name + ); } - fn generate_agent_ado_env(&self, read_service_connection: Option<&str>) -> String { - match read_service_connection { - Some(_) => { - "AZURE_DEVOPS_EXT_PAT: $(SC_READ_TOKEN)\nSYSTEM_ACCESSTOKEN: $(SC_READ_TOKEN)" - .to_string() + params.push("--disable-builtin-mcps".to_string()); + params.push("--no-ask-user".to_string()); + + if use_allow_all_tools { + params.push("--allow-all-tools".to_string()); + } else { + for tool in allowed_tools { + if tool.contains('(') || tool.contains(')') || tool.contains(' ') { + // Use double quotes - the copilot_params are embedded inside a single-quoted + // bash string in the AWF command, so single quotes would break quoting. + params.push(format!("--allow-tool \"{}\"", tool)); + } else { + params.push(format!("--allow-tool {}", tool)); } - None => String::new(), } } + + // --allow-all-paths when edit is enabled — lets the agent write to any file path. + // Emitted independently of --allow-all-tools (matches gh-aw behavior). + if edit_enabled { + params.push("--allow-all-paths".to_string()); + } + + Ok(params.join(" ")) +} + +fn copilot_env() -> String { + let lines = [ + "GITHUB_TOKEN: $(GITHUB_TOKEN)", + "GITHUB_READ_ONLY: 1", + "COPILOT_OTEL_ENABLED: \"true\"", + "COPILOT_OTEL_EXPORTER_TYPE: \"file\"", + "COPILOT_OTEL_FILE_EXPORTER_PATH: \"/tmp/awf-tools/staging/otel.jsonl\"", + ]; + lines.join("\n") } #[cfg(test)] mod tests { - use super::{Engine, GITHUB_COPILOT_CLI_ENGINE}; + use super::{get_engine, Engine}; use crate::compile::{extensions::collect_extensions, parse_markdown}; #[test] - fn copilot_engine_generates_cli_params() { + fn copilot_engine_command() { + assert_eq!(Engine::Copilot.command(), "copilot"); + } + + #[test] + fn copilot_engine_args() { let (front_matter, _) = parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); - let params = GITHUB_COPILOT_CLI_ENGINE - .generate_cli_params(&front_matter, &collect_extensions(&front_matter)) + let params = Engine::Copilot + .args(&front_matter, &collect_extensions(&front_matter)) .unwrap(); + // Default engine (copilot) uses default model (claude-opus-4.5) assert!(params.contains("--model claude-opus-4.5")); assert!(params.contains("--disable-builtin-mcps")); } #[test] - fn copilot_engine_generates_agent_ado_env() { - let env = GITHUB_COPILOT_CLI_ENGINE.generate_agent_ado_env(Some("read-sc")); - assert!(env.contains("AZURE_DEVOPS_EXT_PAT: $(SC_READ_TOKEN)")); - assert!(env.contains("SYSTEM_ACCESSTOKEN: $(SC_READ_TOKEN)")); + fn copilot_engine_with_explicit_model() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n model: gpt-5\n---\n", + ) + .unwrap(); + let params = Engine::Copilot + .args(&front_matter, &collect_extensions(&front_matter)) + .unwrap(); + assert!(params.contains("--model gpt-5")); + } + + #[test] + fn copilot_engine_env() { + let env = Engine::Copilot.env(); + assert!(env.contains("GITHUB_TOKEN: $(GITHUB_TOKEN)")); + assert!(env.contains("GITHUB_READ_ONLY: 1")); + assert!(env.contains("COPILOT_OTEL_ENABLED")); + assert!(!env.contains("SYSTEM_ACCESSTOKEN")); + assert!(!env.contains("AZURE_DEVOPS_EXT_PAT")); + } + + #[test] + fn get_engine_resolves_copilot() { + let engine = get_engine("copilot").unwrap(); + assert_eq!(engine.command(), "copilot"); + let (front_matter, _) = parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + let params = engine + .args(&front_matter, &collect_extensions(&front_matter)) + .unwrap(); + assert!(params.contains("--model claude-opus-4.5")); + } + + #[test] + fn get_engine_rejects_unsupported() { + let result = get_engine("claude"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Unsupported engine 'claude'")); } #[test] - fn copilot_engine_generates_empty_ado_env_without_service_connection() { - assert!(GITHUB_COPILOT_CLI_ENGINE.generate_agent_ado_env(None).is_empty()); + fn get_engine_rejects_codex() { + assert!(get_engine("codex").is_err()); } } diff --git a/src/run.rs b/src/run.rs index 8c8c9d6..14f862a 100644 --- a/src/run.rs +++ b/src/run.rs @@ -659,7 +659,7 @@ pub async fn run(args: &RunArgs) -> Result<()> { println!("=== ado-aw run: {} ===", front_matter.name); println!("Description: {}", front_matter.description); - println!("Engine: {}", front_matter.engine.model()); + 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)"); } @@ -759,6 +759,11 @@ pub async fn run(args: &RunArgs) -> Result<()> { 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. @@ -766,8 +771,6 @@ pub async fn run(args: &RunArgs) -> Result<()> { .context("Failed to find a free port for MCPG")?; println!("\n=== Generating MCPG config ==="); - let compile_ctx = - compile::extensions::CompileContext::new(&front_matter, &working_dir).await; let mut mcpg_config = compile::generate_mcpg_config(&front_matter, &compile_ctx, &extensions)?; @@ -924,7 +927,7 @@ pub async fn run(args: &RunArgs) -> Result<()> { debug!("Agent prompt written to {}", prompt_path.display()); // ── 7. Build and run copilot command ───────────────────────────── - let copilot_params = compile::generate_copilot_params(&front_matter, &extensions)?; + let copilot_params = compile_ctx.engine.args(compile_ctx.front_matter, &extensions)?; println!("\n=== Copilot CLI ==="); @@ -1071,7 +1074,7 @@ pub async fn run(args: &RunArgs) -> Result<()> { /// Does NOT handle backslash escapes, single quotes, or nested quotes. /// /// This is safe because the input is compiler-controlled output from -/// `generate_copilot_params()`, which only produces double-quoted values +/// `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 { diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index d8eb0b0..e1120cc 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -457,7 +457,6 @@ fn test_compiled_output_no_unreplaced_markers() { /// Verifies that the `triggers.pipeline` front matter field generates: /// - `resources.pipelines` block with the correct source pipeline name /// - `trigger: none` and `pr: none` to suppress CI/PR triggers -/// - A cancel-previous-builds bash step /// - No `schedules:` block (since only a pipeline trigger is configured) #[test] fn test_fixture_pipeline_trigger_agent_compiled_output() { @@ -527,10 +526,10 @@ fn test_fixture_pipeline_trigger_agent_compiled_output() { "Compiled output should disable PR trigger with 'pr: none'" ); - // Should include the cancel-previous-builds step + // cancel-previous-builds was removed — verify it's not present assert!( - compiled.contains("Cancel previous queued builds"), - "Compiled output should include cancel-previous-builds step" + !compiled.contains("Cancel previous queued builds"), + "Compiled output should not include cancel-previous-builds step" ); // Should NOT contain a schedules block (no schedule configured)