diff --git a/AGENTS.md b/AGENTS.md index 7150f5a..73d2702 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -346,12 +346,12 @@ engine: | `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. | +| `version` | string | *(none)* | Engine CLI version to install (e.g., `"0.0.422"`, `"latest"`). When set, overrides `COPILOT_CLI_VERSION`. When `"latest"`, omits the `-Version` flag from NuGet install. | +| `agent` | string | *(none)* | Custom agent file identifier (Copilot only — references `.github/agents/.agent.md`). When set, adds `--agent ` to Copilot CLI args. Must be alphanumeric with hyphens only. | | `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. | +| `command` | string | *(none)* | Custom engine executable path (skips default installation). When set, the NuGet install steps are omitted and the specified path is used in the AWF invocation. Must contain only safe path characters. | > **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. @@ -654,6 +654,7 @@ Should be replaced with the human-readable name from the front matter (e.g., "Da Additional params provided to copilot CLI. The compiler generates: - `--model ` - AI model from `engine` front matter field (default: claude-opus-4.5) +- `--agent ` - Custom agent file identifier from `engine.agent` (only when set; must be alphanumeric + hyphens) - `--no-ask-user` - Prevents interactive prompts - `--disable-builtin-mcps` - Disables all built-in Copilot CLI MCPs (single flag, no argument) - `--allow-all-tools` - When bash is omitted (default) or has a wildcard (`":*"` or `"*"`), allows all tools instead of individual `--allow-tool` flags @@ -951,12 +952,25 @@ Should be replaced with the domain the AWF-sandboxed agent uses to reach MCPG on ## {{ copilot_version }} -Should be replaced with the pinned version of the `Microsoft.Copilot.CLI.linux-x64` NuGet package (defined as `COPILOT_CLI_VERSION` constant in `src/compile/common.rs`). This version is used in the pipeline step that installs the Copilot CLI tool from Azure Artifacts. +Should be replaced with the pinned version of the `Microsoft.Copilot.CLI.linux-x64` NuGet package (defined as `COPILOT_CLI_VERSION` constant in `src/compile/common.rs`). This constant serves as the default version; it can be overridden per-agent via the `engine.version` front matter field. -The generated pipelines install the package from: -``` -https://pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json -``` +**Note:** This marker is no longer used directly in the pipeline templates — it is consumed internally by `{{ engine_install_steps }}`. It remains available as a replacement marker for backwards compatibility. + +## {{ engine_install_steps }} + +Generates the engine CLI install steps (NuGet authentication, package install, binary copy, and version output). The behavior depends on the `engine` front matter configuration: + +- **Default** (no `engine.version` or `engine.command`): Generates the full NuGet install sequence using `COPILOT_CLI_VERSION`. +- **`engine.version: "0.0.422"`**: Uses the specified version instead of `COPILOT_CLI_VERSION` in the `-Version` NuGet flag. +- **`engine.version: "latest"`**: Omits the `-Version` flag entirely, installing the latest available package. +- **`engine.command: /path/to/binary`**: Returns an empty string — no install steps are generated because the user provides their own engine binary. + +## {{ copilot_command }} + +Should be replaced with the path to the engine binary inside the AWF container. + +- **Default**: `/tmp/awf-tools/copilot` (the default location where the installed binary is copied). +- **`engine.command: /path/to/binary`**: The custom path specified in front matter. ### 1ES-Specific Template Markers diff --git a/src/compile/common.rs b/src/compile/common.rs index 5d4996d..3fdfb2b 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1917,6 +1917,8 @@ pub async fn compile_shared( // 4. Generate copilot params let copilot_params = ctx.engine.args(ctx.front_matter, extensions)?; + let engine_install_steps = ctx.engine.install_steps(ctx.front_matter)?; + let copilot_command = ctx.engine.command_path(ctx.front_matter)?; // 5. Compute workspace, working directory, triggers let effective_workspace = compute_effective_workspace( @@ -2018,6 +2020,8 @@ pub async fn compile_shared( ("{{ 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 3948e4b..afe87f9 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_mcpg_config; pub use common::MCPG_IMAGE; pub use common::MCPG_VERSION; diff --git a/src/data/1es-base.yml b/src/data/1es-base.yml index ab28da2..e55fd0c 100644 --- a/src/data/1es-base.yml +++ b/src/data/1es-base.yml @@ -56,30 +56,7 @@ extends: {{ acquire_ado_token }} - - 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 }}" @@ -368,7 +345,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" \ @@ -448,29 +425,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 }}" @@ -556,7 +511,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 e101836..0bb9997 100644 --- a/src/data/base.yml +++ b/src/data/base.yml @@ -27,30 +27,7 @@ jobs: {{ acquire_ado_token }} - - 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 }}" @@ -339,7 +316,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" \ @@ -417,29 +394,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 }}" @@ -525,7 +480,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 81234ae..7126589 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,11 +1,15 @@ use anyhow::Result; +use crate::compile::COPILOT_CLI_VERSION; use crate::compile::extensions::{CompilerExtension, Extension}; use crate::compile::types::{FrontMatter, McpConfig}; /// 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"; +/// Default path where the engine binary is placed for AWF container access. +const DEFAULT_COPILOT_COMMAND_PATH: &str = "/tmp/awf-tools/copilot"; + /// Resolved engine — enum dispatch over supported engine identifiers. /// /// Currently only `Copilot` (GitHub Copilot CLI) is supported. New engines @@ -32,11 +36,6 @@ pub fn get_engine(engine_id: &str) -> Result { 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 { @@ -61,6 +60,27 @@ impl Engine { Engine::Copilot => copilot_env(), } } + + /// Resolve the command path for the engine binary inside the AWF container. + /// + /// When `engine.command` is set in front matter, the custom path is used + /// (skipping the default install). Otherwise returns the default path + /// (`/tmp/awf-tools/copilot`). + pub fn command_path(&self, front_matter: &FrontMatter) -> Result { + match self { + Engine::Copilot => copilot_command_path(front_matter), + } + } + + /// Generate the YAML install steps for the engine (NuGet install + binary copy). + /// + /// When `engine.command` is set, install steps are skipped (empty string) + /// because the user is providing their own engine binary. + pub fn install_steps(&self, front_matter: &FrontMatter) -> Result { + match self { + Engine::Copilot => copilot_install_steps(front_matter), + } + } } fn copilot_args( @@ -224,20 +244,6 @@ fn copilot_args( 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 \ @@ -245,13 +251,6 @@ fn copilot_args( 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 \ @@ -260,6 +259,22 @@ fn copilot_args( ); } + // Validate and wire engine.agent — add --agent to Copilot CLI args + if let Some(agent) = front_matter.engine.agent() { + if agent.is_empty() + || !agent + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + anyhow::bail!( + "engine.agent '{}' contains invalid characters. \ + Only ASCII alphanumerics and hyphens are allowed.", + agent + ); + } + params.push(format!("--agent {}", agent)); + } + params.push("--disable-builtin-mcps".to_string()); params.push("--no-ask-user".to_string()); @@ -286,6 +301,130 @@ fn copilot_args( Ok(params.join(" ")) } +/// Validate an engine command path for safety. +/// +/// Rejects shell metacharacters that could break the AWF invocation (the command +/// is embedded inside a single-quoted bash string). Accepts absolute paths and +/// bare binary names. +fn validate_engine_command(cmd: &str) -> Result<()> { + if cmd.is_empty() { + anyhow::bail!("engine.command must not be empty"); + } + // Allow only safe characters: alphanumeric, path separators, dots, hyphens, underscores + if !cmd + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '-' | '_')) + { + anyhow::bail!( + "engine.command '{}' contains invalid characters. \ + Only ASCII alphanumerics, '/', '.', '-', and '_' are allowed.", + cmd + ); + } + Ok(()) +} + +/// Validate an engine version string for safety. +/// +/// The version is embedded in a NuGet command line, so it must not contain shell +/// metacharacters or whitespace. +fn validate_engine_version(version: &str) -> Result<()> { + if version.is_empty() { + anyhow::bail!("engine.version must not be empty"); + } + // "latest" is a special value that omits the -Version flag + if version == "latest" { + return Ok(()); + } + // Allow only safe version characters: alphanumeric, dots, hyphens, underscores + if !version + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + { + anyhow::bail!( + "engine.version '{}' contains invalid characters. \ + Only ASCII alphanumerics, '.', '-', and '_' are allowed (or \"latest\").", + version + ); + } + Ok(()) +} + +/// Resolve the Copilot CLI command path inside the AWF container. +/// +/// When `engine.command` is set, returns the custom path. +/// Otherwise returns the default path (`/tmp/awf-tools/copilot`). +fn copilot_command_path(front_matter: &FrontMatter) -> Result { + match front_matter.engine.command() { + Some(cmd) => { + validate_engine_command(cmd)?; + Ok(cmd.to_string()) + } + None => Ok(DEFAULT_COPILOT_COMMAND_PATH.to_string()), + } +} + +/// Generate the Copilot CLI install steps for the pipeline. +/// +/// When `engine.command` is set, returns an empty string (no install needed). +/// When `engine.version` is `"latest"`, omits the `-Version` flag from the NuGet command. +/// Otherwise uses the specified version (or the default `COPILOT_CLI_VERSION` constant). +/// +/// The returned string uses no leading indentation — `replace_with_indent` will +/// add the correct indent based on where `{{ engine_install_steps }}` appears +/// in the template. +fn copilot_install_steps(front_matter: &FrontMatter) -> Result { + // When a custom command is provided, skip all install steps. + // Validation of the command path is handled by copilot_command_path(), + // which runs earlier in the compilation flow. + if front_matter.engine.command().is_some() { + return Ok(String::new()); + } + + // Determine the NuGet version argument + let version_arg = match front_matter.engine.version() { + Some("latest") => String::new(), // Omit -Version flag entirely + Some(v) => { + validate_engine_version(v)?; + format!(" -Version {v}") + } + None => format!(" -Version {}", COPILOT_CLI_VERSION), + }; + + let nuget_source = "https://pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json"; + let nuget_args = format!( + "install Microsoft.Copilot.CLI.linux-x64 -Source \"{nuget_source}\"{version_arg} -OutputDirectory $(Agent.TempDirectory)/tools -ExcludeVersion -NonInteractive" + ); + + let mut lines = Vec::new(); + lines.push("- task: NuGetAuthenticate@1".to_string()); + lines.push(" displayName: \"Authenticate NuGet Feed\"".to_string()); + lines.push(String::new()); + lines.push("- task: NuGetCommand@2".to_string()); + lines.push(" displayName: \"Install Copilot CLI\"".to_string()); + lines.push(" inputs:".to_string()); + lines.push(" command: 'custom'".to_string()); + lines.push(format!(" arguments: '{nuget_args}'")); + lines.push(String::new()); + lines.push("- bash: |".to_string()); + lines.push(" ls -la \"$(Agent.TempDirectory)/tools\"".to_string()); + lines.push(" echo \"##vso[task.prependpath]$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64\"".to_string()); + lines.push(String::new()); + lines.push(" # Copy copilot binary to /tmp so it's accessible inside AWF container".to_string()); + lines.push(" # (AWF auto-mounts /tmp:/tmp:rw but not Agent.TempDirectory)".to_string()); + lines.push(" mkdir -p /tmp/awf-tools".to_string()); + lines.push(" cp \"$(Agent.TempDirectory)/tools/Microsoft.Copilot.CLI.linux-x64/copilot\" /tmp/awf-tools/copilot".to_string()); + lines.push(" chmod +x /tmp/awf-tools/copilot".to_string()); + lines.push(" displayName: \"Add copilot to PATH\"".to_string()); + lines.push(String::new()); + lines.push("- bash: |".to_string()); + lines.push(" copilot --version".to_string()); + lines.push(" copilot -h".to_string()); + lines.push(" displayName: \"Output copilot version\"".to_string()); + + Ok(lines.join("\n")) +} + fn copilot_env() -> String { let lines = [ "GITHUB_TOKEN: $(GITHUB_TOKEN)", @@ -363,4 +502,132 @@ mod tests { fn get_engine_rejects_codex() { assert!(get_engine("codex").is_err()); } + + // ─── engine.agent tests ───────────────────────────────────────── + + #[test] + fn copilot_args_with_agent() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n agent: my-agent\n---\n", + ) + .unwrap(); + let params = Engine::Copilot + .args(&fm, &collect_extensions(&fm)) + .unwrap(); + assert!(params.contains("--agent my-agent")); + } + + #[test] + fn copilot_args_without_agent() { + let (fm, _) = parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + let params = Engine::Copilot + .args(&fm, &collect_extensions(&fm)) + .unwrap(); + assert!(!params.contains("--agent")); + } + + #[test] + fn copilot_args_agent_validation_rejects_path_separators() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n agent: ../etc/passwd\n---\n", + ) + .unwrap(); + let result = Engine::Copilot.args(&fm, &collect_extensions(&fm)); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid characters")); + } + + #[test] + fn copilot_args_agent_validation_rejects_spaces() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n agent: \"my agent\"\n---\n", + ) + .unwrap(); + let result = Engine::Copilot.args(&fm, &collect_extensions(&fm)); + assert!(result.is_err()); + } + + // ─── engine.command tests ─────────────────────────────────────── + + #[test] + fn command_path_default() { + let (fm, _) = parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + let path = Engine::Copilot.command_path(&fm).unwrap(); + assert_eq!(path, "/tmp/awf-tools/copilot"); + } + + #[test] + fn command_path_custom() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n command: /usr/local/bin/my-copilot\n---\n", + ) + .unwrap(); + let path = Engine::Copilot.command_path(&fm).unwrap(); + assert_eq!(path, "/usr/local/bin/my-copilot"); + } + + #[test] + fn command_path_rejects_shell_metacharacters() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n command: \"/bin/sh; rm -rf /\"\n---\n", + ) + .unwrap(); + let result = Engine::Copilot.command_path(&fm); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid characters")); + } + + #[test] + fn install_steps_default_includes_version() { + let (fm, _) = parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + let steps = Engine::Copilot.install_steps(&fm).unwrap(); + assert!(steps.contains("NuGetAuthenticate@1")); + assert!(steps.contains(&format!("-Version {}", super::COPILOT_CLI_VERSION))); + assert!(steps.contains("Add copilot to PATH")); + } + + #[test] + fn install_steps_custom_version() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n version: \"0.0.422\"\n---\n", + ) + .unwrap(); + let steps = Engine::Copilot.install_steps(&fm).unwrap(); + assert!(steps.contains("-Version 0.0.422")); + assert!(!steps.contains(&format!("-Version {}", super::COPILOT_CLI_VERSION))); + } + + #[test] + fn install_steps_latest_version_omits_flag() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n version: \"latest\"\n---\n", + ) + .unwrap(); + let steps = Engine::Copilot.install_steps(&fm).unwrap(); + assert!(!steps.contains("-Version ")); + assert!(steps.contains("NuGetAuthenticate@1")); + } + + #[test] + fn install_steps_skipped_when_custom_command() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n command: /usr/local/bin/copilot\n---\n", + ) + .unwrap(); + let steps = Engine::Copilot.install_steps(&fm).unwrap(); + assert!(steps.is_empty(), "install steps should be empty when engine.command is set"); + } + + // ─── engine.version validation tests ──────────────────────────── + + #[test] + fn version_validation_rejects_shell_injection() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nengine:\n id: copilot\n version: \"1.0; rm -rf /\"\n---\n", + ) + .unwrap(); + let result = Engine::Copilot.install_steps(&fm); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid characters")); + } }