From e68087479c79d03fc9a862064941de0f21959936 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:21:57 +0000 Subject: [PATCH 1/3] Initial plan From a46eda4e3a6c70308021e526838e97d794dcba6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:32:28 +0000 Subject: [PATCH 2/3] feat: wire engine.version, engine.command, and engine.agent through to pipeline output Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/64a76476-e26a-4be4-8120-8eb469a0ef35 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/common.rs | 21 ++++++- src/compile/mod.rs | 1 + src/compile/types.rs | 36 ++++++++++++ src/data/1es-base.yml | 53 ++--------------- src/data/base.yml | 53 ++--------------- src/engine.rs | 133 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 99 deletions(-) diff --git a/src/compile/common.rs b/src/compile/common.rs index b2c793d..5143072 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -467,6 +467,20 @@ pub fn generate_copilot_params( crate::engine::GITHUB_COPILOT_CLI_ENGINE.generate_cli_params(front_matter, extensions) } +/// Generate engine install steps for the pipeline template. +/// Delegates to the engine implementation which handles `engine.version` and `engine.command`. +pub fn generate_engine_install_steps(front_matter: &FrontMatter) -> Result { + use crate::engine::Engine as _; + crate::engine::GITHUB_COPILOT_CLI_ENGINE.generate_install_steps(front_matter) +} + +/// Generate the engine command path for the AWF invocation. +/// Delegates to the engine implementation which handles `engine.command`. +pub fn generate_copilot_command(front_matter: &FrontMatter) -> Result { + use crate::engine::Engine as _; + crate::engine::GITHUB_COPILOT_CLI_ENGINE.generate_command_path(front_matter) +} + /// Compute the effective workspace based on explicit setting and checkout configuration. pub fn compute_effective_workspace( explicit_workspace: &Option, @@ -1969,6 +1983,10 @@ pub async fn compile_shared( // 4. Generate copilot params let copilot_params = generate_copilot_params(front_matter, extensions)?; + // 4b. Generate engine install steps and command path + let engine_install_steps = generate_engine_install_steps(front_matter)?; + let copilot_command = generate_copilot_command(front_matter)?; + // 5. Compute workspace, working directory, triggers let effective_workspace = compute_effective_workspace( &front_matter.workspace, @@ -2073,7 +2091,8 @@ pub async fn compile_shared( let replacements: Vec<(&str, &str)> = vec![ ("{{ parameters }}", ¶meters_yaml), ("{{ compiler_version }}", compiler_version), - ("{{ copilot_version }}", COPILOT_CLI_VERSION), + ("{{ engine_install_steps }}", &engine_install_steps), + ("{{ copilot_command }}", &copilot_command), ("{{ pool }}", &pool), ("{{ setup_job }}", &setup_job), ("{{ teardown_job }}", &teardown_job), diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 499bc0a..640e7c7 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -19,6 +19,7 @@ use std::path::{Path, PathBuf}; pub use common::parse_markdown; pub use common::HEADER_MARKER; +pub use common::COPILOT_CLI_VERSION; pub use common::generate_copilot_params; pub use common::generate_mcpg_config; pub use common::MCPG_IMAGE; diff --git a/src/compile/types.rs b/src/compile/types.rs index d7a3039..9c4a295 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -191,6 +191,30 @@ impl EngineConfig { EngineConfig::Full(opts) => opts.timeout_minutes, } } + + /// Get the engine CLI version override + pub fn version(&self) -> Option<&str> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.version.as_deref(), + } + } + + /// Get the custom engine command path override + pub fn command(&self) -> Option<&str> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.command.as_deref(), + } + } + + /// Get the custom Copilot agent identifier + pub fn agent(&self) -> Option<&str> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.agent.as_deref(), + } + } } impl SanitizeConfigTrait for EngineConfig { @@ -213,6 +237,18 @@ pub struct EngineOptions { /// Workflow timeout in minutes #[serde(default, rename = "timeout-minutes")] pub timeout_minutes: Option, + /// Pin the engine CLI to a specific release version (e.g., "0.0.422"). + /// When set to "latest", omits the -Version flag from the NuGet install step. + #[serde(default)] + pub version: Option, + /// Override the default engine executable path. Skips the default installation + /// step when set. Must be an absolute path or a bare binary name. + #[serde(default)] + pub command: Option, + /// Reference a custom Copilot agent file in .github/agents/ + /// (e.g., "technical-doc-writer" → .github/agents/technical-doc-writer.agent.md). + #[serde(default)] + pub agent: Option, } /// Tools configuration for the agent diff --git a/src/data/1es-base.yml b/src/data/1es-base.yml index 4239d89..09ae89b 100644 --- a/src/data/1es-base.yml +++ b/src/data/1es-base.yml @@ -58,30 +58,7 @@ extends: {{ cancel_previous_builds }} - - task: NuGetAuthenticate@1 - displayName: "Authenticate NuGet Feed" - - - task: NuGetCommand@2 - displayName: "Install Copilot CLI" - inputs: - command: 'custom' - arguments: 'install Microsoft.Copilot.CLI.linux-x64 -Source "https://pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json" -Version {{ copilot_version }} -OutputDirectory $(Agent.TempDirectory)/tools -ExcludeVersion -NonInteractive' - - - bash: | - ls -la "$(Agent.TempDirectory)/tools" - echo "##vso[task.prependpath]$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64" - - # Copy copilot binary to /tmp so it's accessible inside AWF container - # (AWF auto-mounts /tmp:/tmp:rw but not Agent.TempDirectory) - mkdir -p /tmp/awf-tools - cp "$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64/copilot" /tmp/awf-tools/copilot - chmod +x /tmp/awf-tools/copilot - displayName: "Add copilot to PATH" - - - bash: | - copilot --version - copilot -h - displayName: "Output copilot version" + {{ engine_install_steps }} - bash: | COMPILER_VERSION="{{ compiler_version }}" @@ -370,7 +347,7 @@ extends: --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json {{ copilot_params }}' \ + -- '{{ copilot_command }} --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json {{ copilot_params }}' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ @@ -455,29 +432,7 @@ extends: - download: current artifact: agent_outputs_$(Build.BuildId) - - task: NuGetAuthenticate@1 - displayName: "Authenticate NuGet Feed" - - - task: NuGetCommand@2 - displayName: "Install Copilot CLI" - inputs: - command: 'custom' - arguments: 'install Microsoft.Copilot.CLI.linux-x64 -Source "https://pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json" -Version {{ copilot_version }} -OutputDirectory $(Agent.TempDirectory)/tools -ExcludeVersion -NonInteractive' - - - bash: | - ls -la "$(Agent.TempDirectory)/tools" - echo "##vso[task.prependpath]$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64" - - # Copy copilot binary to /tmp so it's accessible inside AWF container - mkdir -p /tmp/awf-tools - cp "$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64/copilot" /tmp/awf-tools/copilot - chmod +x /tmp/awf-tools/copilot - displayName: "Add copilot to PATH" - - - bash: | - copilot --version - copilot -h - displayName: "Output copilot version" + {{ engine_install_steps }} - bash: | COMPILER_VERSION="{{ compiler_version }}" @@ -563,7 +518,7 @@ extends: --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" {{ copilot_params }}' \ + -- '{{ copilot_command }} --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" {{ copilot_params }}' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$THREAT_OUTPUT_FILE" \ diff --git a/src/data/base.yml b/src/data/base.yml index ec51af2..ddd3ac9 100644 --- a/src/data/base.yml +++ b/src/data/base.yml @@ -29,30 +29,7 @@ jobs: {{ cancel_previous_builds }} - - task: NuGetAuthenticate@1 - displayName: "Authenticate NuGet Feed" - - - task: NuGetCommand@2 - displayName: "Install Copilot CLI" - inputs: - command: 'custom' - arguments: 'install Microsoft.Copilot.CLI.linux-x64 -Source "https://pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json" -Version {{ copilot_version }} -OutputDirectory $(Agent.TempDirectory)/tools -ExcludeVersion -NonInteractive' - - - bash: | - ls -la "$(Agent.TempDirectory)/tools" - echo "##vso[task.prependpath]$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64" - - # Copy copilot binary to /tmp so it's accessible inside AWF container - # (AWF auto-mounts /tmp:/tmp:rw but not Agent.TempDirectory) - mkdir -p /tmp/awf-tools - cp "$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64/copilot" /tmp/awf-tools/copilot - chmod +x /tmp/awf-tools/copilot - displayName: "Add copilot to PATH" - - - bash: | - copilot --version - copilot -h - displayName: "Output copilot version" + {{ engine_install_steps }} - bash: | COMPILER_VERSION="{{ compiler_version }}" @@ -341,7 +318,7 @@ jobs: --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json {{ copilot_params }}' \ + -- '{{ copilot_command }} --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json {{ copilot_params }}' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ @@ -424,29 +401,7 @@ jobs: - download: current artifact: agent_outputs_$(Build.BuildId) - - task: NuGetAuthenticate@1 - displayName: "Authenticate NuGet Feed" - - - task: NuGetCommand@2 - displayName: "Install Copilot CLI" - inputs: - command: 'custom' - arguments: 'install Microsoft.Copilot.CLI.linux-x64 -Source "https://pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json" -Version {{ copilot_version }} -OutputDirectory $(Agent.TempDirectory)/tools -ExcludeVersion -NonInteractive' - - - bash: | - ls -la "$(Agent.TempDirectory)/tools" - echo "##vso[task.prependpath]$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64" - - # Copy copilot binary to /tmp so it's accessible inside AWF container - mkdir -p /tmp/awf-tools - cp "$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64/copilot" /tmp/awf-tools/copilot - chmod +x /tmp/awf-tools/copilot - displayName: "Add copilot to PATH" - - - bash: | - copilot --version - copilot -h - displayName: "Output copilot version" + {{ engine_install_steps }} - bash: | COMPILER_VERSION="{{ compiler_version }}" @@ -532,7 +487,7 @@ jobs: --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" {{ copilot_params }}' \ + -- '{{ copilot_command }} --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" {{ copilot_params }}' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$THREAT_OUTPUT_FILE" \ diff --git a/src/engine.rs b/src/engine.rs index 63b85ef..a58e222 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,5 +1,6 @@ use anyhow::Result; +use crate::compile::COPILOT_CLI_VERSION; use crate::compile::extensions::{CompilerExtension, Extension}; use crate::compile::types::{FrontMatter, McpConfig}; @@ -11,6 +12,14 @@ pub trait Engine { ) -> Result; fn generate_agent_ado_env(&self, read_service_connection: Option<&str>) -> String; + + /// Generate the pipeline install steps for the engine CLI. + /// Returns empty string when `engine.command` is set (custom command, skip install). + fn generate_install_steps(&self, front_matter: &FrontMatter) -> Result; + + /// Return the engine command path used inside the AWF container. + /// Returns `engine.command` when set, otherwise the default NuGet-installed path. + fn generate_command_path(&self, front_matter: &FrontMatter) -> Result; } pub struct GitHubCopilotCliEngine; @@ -192,6 +201,25 @@ impl Engine for GitHubCopilotCliEngine { params.push("--allow-all-paths".to_string()); } + // --agent when engine.agent is set — references a custom Copilot agent + // file in .github/agents/ (e.g., "technical-doc-writer" → + // .github/agents/technical-doc-writer.agent.md). Copilot-only feature. + if let Some(agent) = front_matter.engine.agent() { + // Validate: alphanumeric + hyphens only (no path separators, shell metacharacters) + if agent.is_empty() + || !agent + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + anyhow::bail!( + "Agent identifier '{}' contains invalid characters. \ + Only ASCII alphanumerics and hyphens are allowed.", + agent + ); + } + params.push(format!("--agent {}", agent)); + } + Ok(params.join(" ")) } @@ -204,6 +232,111 @@ impl Engine for GitHubCopilotCliEngine { None => String::new(), } } + + fn generate_install_steps(&self, front_matter: &FrontMatter) -> Result { + // When engine.command is set, skip install entirely — the user provides + // a pre-installed binary. + if front_matter.engine.command().is_some() { + return Ok(String::new()); + } + + // Determine the NuGet -Version flag + let version_flag = match front_matter.engine.version() { + Some(v) if v.eq_ignore_ascii_case("latest") => { + // "latest" → omit -Version flag; NuGet resolves to latest available + String::new() + } + Some(v) => { + // Validate version string for shell safety + if v.is_empty() + || !v + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')) + { + anyhow::bail!( + "Engine version '{}' contains invalid characters. \ + Only ASCII alphanumerics, '.', '-', and '+' are allowed.", + v + ); + } + format!(" -Version {}", v) + } + None => { + // Default: use the pinned COPILOT_CLI_VERSION constant + format!(" -Version {}", COPILOT_CLI_VERSION) + } + }; + + Ok(format!( + r###"- task: NuGetAuthenticate@1 + displayName: "Authenticate NuGet Feed" + +- task: NuGetCommand@2 + displayName: "Install Copilot CLI" + inputs: + command: 'custom' + arguments: 'install Microsoft.Copilot.CLI.linux-x64 -Source "https://pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json"{version_flag} -OutputDirectory $(Agent.TempDirectory)/tools -ExcludeVersion -NonInteractive' + +- bash: | + ls -la "$(Agent.TempDirectory)/tools" + echo "##vso[task.prependpath]$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64" + + # Copy copilot binary to /tmp so it's accessible inside AWF container + # (AWF auto-mounts /tmp:/tmp:rw but not Agent.TempDirectory) + mkdir -p /tmp/awf-tools + cp "$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64/copilot" /tmp/awf-tools/copilot + chmod +x /tmp/awf-tools/copilot + displayName: "Add copilot to PATH" + +- bash: | + copilot --version + copilot -h + displayName: "Output copilot version""###, + version_flag = version_flag + )) + } + + fn generate_command_path(&self, front_matter: &FrontMatter) -> Result { + match front_matter.engine.command() { + Some(cmd) => { + // Validate: must be an absolute path or a bare binary name (no shell + // metacharacters). The command is embedded in a single-quoted bash string + // inside the AWF invocation, so no shell injection is possible, but we + // still validate for correctness and to catch operator mistakes. + if cmd.is_empty() { + anyhow::bail!("Engine command path must not be empty."); + } + // Reject shell metacharacters and path traversal + if cmd.contains('\'') + || cmd.contains('"') + || cmd.contains('`') + || cmd.contains('$') + || cmd.contains(';') + || cmd.contains('|') + || cmd.contains('&') + || cmd.contains('\n') + || cmd.contains("..") + { + anyhow::bail!( + "Engine command '{}' contains shell metacharacters or path traversal. \ + Must be an absolute path or a bare binary name.", + cmd + ); + } + // Must be an absolute path (starts with /) or a bare binary name (no slashes at all) + let has_slash = cmd.contains('/'); + if has_slash && !cmd.starts_with('/') { + anyhow::bail!( + "Engine command '{}' must be an absolute path (starting with /) \ + or a bare binary name (no path separators).", + cmd + ); + } + Ok(cmd.to_string()) + } + None => Ok("/tmp/awf-tools/copilot".to_string()), + } + } } #[cfg(test)] From 008289f8b260158d8db0d66af19bbead160261aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:36:50 +0000 Subject: [PATCH 3/3] test: add tests for engine.version, engine.command, and engine.agent Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/64a76476-e26a-4be4-8120-8eb469a0ef35 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/common.rs | 80 +++++++++++++++++ src/compile/types.rs | 59 ++++++++++++ src/engine.rs | 202 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+) diff --git a/src/compile/common.rs b/src/compile/common.rs index 5143072..d0550db 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2535,6 +2535,86 @@ mod tests { assert_eq!(generate_job_timeout(&fm), "timeoutInMinutes: 0"); } + // ─── generate_engine_install_steps ───────────────────────────────────────── + + #[test] + fn test_engine_install_steps_default() { + let fm = minimal_front_matter(); + let steps = generate_engine_install_steps(&fm).unwrap(); + assert!(steps.contains("NuGetAuthenticate@1")); + assert!(steps.contains(&format!("-Version {}", COPILOT_CLI_VERSION))); + } + + #[test] + fn test_engine_install_steps_custom_version() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n version: \"2.0.0\"\n---\n", + ) + .unwrap(); + let steps = generate_engine_install_steps(&fm).unwrap(); + assert!(steps.contains("-Version 2.0.0")); + assert!(!steps.contains(COPILOT_CLI_VERSION)); + } + + #[test] + fn test_engine_install_steps_latest_omits_version() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n version: latest\n---\n", + ) + .unwrap(); + let steps = generate_engine_install_steps(&fm).unwrap(); + assert!(!steps.contains("-Version")); + assert!(steps.contains("NuGetAuthenticate@1")); + } + + #[test] + fn test_engine_install_steps_skipped_with_command() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n command: /usr/bin/copilot\n---\n", + ) + .unwrap(); + let steps = generate_engine_install_steps(&fm).unwrap(); + assert!(steps.is_empty()); + } + + // ─── generate_copilot_command ───────────────────────────────────────────── + + #[test] + fn test_copilot_command_default() { + let fm = minimal_front_matter(); + let cmd = generate_copilot_command(&fm).unwrap(); + assert_eq!(cmd, "/tmp/awf-tools/copilot"); + } + + #[test] + fn test_copilot_command_custom() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n command: /usr/local/bin/my-engine\n---\n", + ) + .unwrap(); + let cmd = generate_copilot_command(&fm).unwrap(); + assert_eq!(cmd, "/usr/local/bin/my-engine"); + } + + // ─── generate_copilot_params with agent ─────────────────────────────────── + + #[test] + fn test_copilot_params_with_agent() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n agent: my-agent\n---\n", + ) + .unwrap(); + let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + assert!(params.contains("--agent my-agent")); + } + + #[test] + fn test_copilot_params_no_agent_by_default() { + let fm = minimal_front_matter(); + let params = generate_copilot_params(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); + assert!(!params.contains("--agent")); + } + // ─── sanitize_filename ──────────────────────────────────────────────────── #[test] diff --git a/src/compile/types.rs b/src/compile/types.rs index 9c4a295..438b7a4 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -937,6 +937,65 @@ mod tests { assert_eq!(ec.timeout_minutes(), Some(30)); } + #[test] + fn test_engine_config_version_accessor() { + let yaml = "version: \"1.2.3\""; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let ec = EngineConfig::Full(opts); + assert_eq!(ec.version(), Some("1.2.3")); + } + + #[test] + fn test_engine_config_version_none_by_default() { + let ec = EngineConfig::default(); + assert_eq!(ec.version(), None); + } + + #[test] + fn test_engine_config_version_none_for_simple() { + let ec = EngineConfig::Simple("claude-opus-4.5".to_string()); + assert_eq!(ec.version(), None); + } + + #[test] + fn test_engine_config_command_accessor() { + let yaml = "command: /usr/local/bin/my-copilot"; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let ec = EngineConfig::Full(opts); + assert_eq!(ec.command(), Some("/usr/local/bin/my-copilot")); + } + + #[test] + fn test_engine_config_command_none_by_default() { + let ec = EngineConfig::default(); + assert_eq!(ec.command(), None); + } + + #[test] + fn test_engine_config_agent_accessor() { + let yaml = "agent: technical-doc-writer"; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let ec = EngineConfig::Full(opts); + assert_eq!(ec.agent(), Some("technical-doc-writer")); + } + + #[test] + fn test_engine_config_agent_none_by_default() { + let ec = EngineConfig::default(); + assert_eq!(ec.agent(), None); + } + + #[test] + fn test_engine_config_full_with_all_new_fields() { + let yaml = "model: claude-opus-4.5\nversion: \"0.0.422\"\ncommand: /usr/bin/copilot\nagent: my-agent"; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let ec = EngineConfig::Full(opts); + assert_eq!(ec.model(), "claude-opus-4.5"); + assert_eq!(ec.version(), Some("0.0.422")); + assert_eq!(ec.command(), Some("/usr/bin/copilot")); + assert_eq!(ec.agent(), Some("my-agent")); + } + // ─── PermissionsConfig deserialization ─────────────────────────────── #[test] diff --git a/src/engine.rs b/src/engine.rs index a58e222..ee8e22f 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -365,4 +365,206 @@ mod tests { fn copilot_engine_generates_empty_ado_env_without_service_connection() { assert!(GITHUB_COPILOT_CLI_ENGINE.generate_agent_ado_env(None).is_empty()); } + + // ─── engine.agent ──────────────────────────────────────────────────────── + + #[test] + fn copilot_engine_agent_flag_when_set() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n agent: technical-doc-writer\n---\n", + ) + .unwrap(); + let params = GITHUB_COPILOT_CLI_ENGINE + .generate_cli_params(&front_matter, &collect_extensions(&front_matter)) + .unwrap(); + assert!( + params.contains("--agent technical-doc-writer"), + "Expected --agent flag, got: {}", + params + ); + } + + #[test] + fn copilot_engine_no_agent_flag_by_default() { + 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)) + .unwrap(); + assert!( + !params.contains("--agent"), + "Should not have --agent flag by default, got: {}", + params + ); + } + + #[test] + fn copilot_engine_agent_rejects_path_separators() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n agent: ../evil/agent\n---\n", + ) + .unwrap(); + let result = GITHUB_COPILOT_CLI_ENGINE + .generate_cli_params(&front_matter, &collect_extensions(&front_matter)); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid characters")); + } + + #[test] + fn copilot_engine_agent_rejects_empty() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n agent: \"\"\n---\n", + ) + .unwrap(); + let result = GITHUB_COPILOT_CLI_ENGINE + .generate_cli_params(&front_matter, &collect_extensions(&front_matter)); + assert!(result.is_err()); + } + + // ─── engine.version (install steps) ───────────────────────────────────── + + #[test] + fn install_steps_default_version() { + let (front_matter, _) = + parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + let steps = GITHUB_COPILOT_CLI_ENGINE + .generate_install_steps(&front_matter) + .unwrap(); + assert!( + steps.contains(&format!("-Version {}", crate::compile::COPILOT_CLI_VERSION)), + "Should contain default version, got: {}", + steps + ); + assert!(steps.contains("NuGetAuthenticate@1")); + assert!(steps.contains("Install Copilot CLI")); + } + + #[test] + fn install_steps_custom_version() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n version: \"1.2.3\"\n---\n", + ) + .unwrap(); + let steps = GITHUB_COPILOT_CLI_ENGINE + .generate_install_steps(&front_matter) + .unwrap(); + assert!( + steps.contains("-Version 1.2.3"), + "Should contain custom version, got: {}", + steps + ); + } + + #[test] + fn install_steps_latest_version_omits_version_flag() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n version: latest\n---\n", + ) + .unwrap(); + let steps = GITHUB_COPILOT_CLI_ENGINE + .generate_install_steps(&front_matter) + .unwrap(); + assert!( + !steps.contains("-Version"), + "Should not contain -Version flag when 'latest', got: {}", + steps + ); + // Still contains install steps + assert!(steps.contains("NuGetAuthenticate@1")); + } + + #[test] + fn install_steps_version_rejects_shell_metacharacters() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n version: \"1.0; evil\"\n---\n", + ) + .unwrap(); + let result = GITHUB_COPILOT_CLI_ENGINE.generate_install_steps(&front_matter); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid characters")); + } + + // ─── engine.command ───────────────────────────────────────────────────── + + #[test] + fn install_steps_skipped_when_command_set() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n command: /usr/local/bin/my-copilot\n---\n", + ) + .unwrap(); + let steps = GITHUB_COPILOT_CLI_ENGINE + .generate_install_steps(&front_matter) + .unwrap(); + assert!( + steps.is_empty(), + "Install steps should be empty when command is set, got: {}", + steps + ); + } + + #[test] + fn command_path_default() { + let (front_matter, _) = + parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + let cmd = GITHUB_COPILOT_CLI_ENGINE + .generate_command_path(&front_matter) + .unwrap(); + assert_eq!(cmd, "/tmp/awf-tools/copilot"); + } + + #[test] + fn command_path_custom_absolute() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n command: /usr/local/bin/my-copilot\n---\n", + ) + .unwrap(); + let cmd = GITHUB_COPILOT_CLI_ENGINE + .generate_command_path(&front_matter) + .unwrap(); + assert_eq!(cmd, "/usr/local/bin/my-copilot"); + } + + #[test] + fn command_path_bare_binary() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n command: my-copilot\n---\n", + ) + .unwrap(); + let cmd = GITHUB_COPILOT_CLI_ENGINE + .generate_command_path(&front_matter) + .unwrap(); + assert_eq!(cmd, "my-copilot"); + } + + #[test] + fn command_path_rejects_relative_path() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n command: bin/copilot\n---\n", + ) + .unwrap(); + let result = GITHUB_COPILOT_CLI_ENGINE.generate_command_path(&front_matter); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("absolute path")); + } + + #[test] + fn command_path_rejects_shell_metacharacters() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n command: \"/tmp/copilot; rm -rf /\"\n---\n", + ) + .unwrap(); + let result = GITHUB_COPILOT_CLI_ENGINE.generate_command_path(&front_matter); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("shell metacharacters")); + } + + #[test] + fn command_path_rejects_path_traversal() { + let (front_matter, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n command: /tmp/../etc/evil\n---\n", + ) + .unwrap(); + let result = GITHUB_COPILOT_CLI_ENGINE.generate_command_path(&front_matter); + assert!(result.is_err()); + } }