Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2962,7 +2962,7 @@ pub async fn compile_shared(
"/tmp/awf-tools/threat-analysis-prompt.md",
None,
)?;
let engine_install_steps = ctx.engine.install_steps(&front_matter.engine)?;
let engine_install_steps = ctx.engine.install_steps(&front_matter.engine, &front_matter.target)?;

// 5. Compute workspace, working directory, triggers
let effective_workspace = compute_effective_workspace(
Expand Down
175 changes: 147 additions & 28 deletions src/engine.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::Result;

use crate::compile::extensions::{CompilerExtension, Extension};
use crate::compile::types::{EngineConfig, FrontMatter, McpConfig};
use crate::compile::types::{CompileTarget, EngineConfig, FrontMatter, McpConfig};
use crate::validate::{
contains_ado_expression, contains_newline, contains_pipeline_command, is_valid_arg,
is_valid_command_path, is_valid_env_var_name, is_valid_hostname, is_valid_identifier,
Expand Down Expand Up @@ -40,9 +40,10 @@ pub const BLOCKED_ENV_KEYS: &[&str] = &[
/// Default model used by the Copilot engine when no model is specified in front matter.
pub const DEFAULT_COPILOT_MODEL: &str = "claude-opus-4.7";

/// Default pinned version of the Copilot CLI NuGet package.
/// Default pinned version of the Copilot CLI.
/// Override per-agent via `engine: { id: copilot, version: "1.0.35" }` in front matter.
pub const COPILOT_CLI_VERSION: &str = "1.0.47";
const COPILOT_CLI_RELEASES_BASE: &str = "https://github.com/github/copilot-cli/releases";

/// Resolved engine — enum dispatch over supported engine identifiers.
///
Expand Down Expand Up @@ -134,9 +135,9 @@ impl Engine {
/// Uses `engine_config.version()` if set in front matter, otherwise falls back
/// to the pinned `COPILOT_CLI_VERSION` constant. Returns an empty string when
/// `engine.command` is set (the user provides their own binary).
pub fn install_steps(&self, engine_config: &EngineConfig) -> Result<String> {
pub fn install_steps(&self, engine_config: &EngineConfig, target: &CompileTarget) -> Result<String> {
match self {
Engine::Copilot => copilot_install_steps(engine_config),
Engine::Copilot => copilot_install_steps(engine_config, target),
}
}

Expand Down Expand Up @@ -498,10 +499,12 @@ fn copilot_env(engine_config: &EngineConfig) -> Result<String> {

/// Generate Copilot CLI install steps for Azure DevOps pipelines.
///
/// Produces the YAML block that authenticates with NuGet, installs the
/// `Microsoft.Copilot.CLI.linux-x64` package, copies the binary to
/// `/tmp/awf-tools/copilot`, and verifies the install.
fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
/// Produces target-specific YAML:
/// - 1ES: authenticate with NuGet and install `Microsoft.Copilot.CLI.linux-x64`.
/// - Non-1ES: download Copilot CLI from GitHub Releases and verify SHA256.
///
/// Both paths stage the binary at `/tmp/awf-tools/copilot`.
fn copilot_install_steps(engine_config: &EngineConfig, target: &CompileTarget) -> Result<String> {
// Custom binary path → skip NuGet install entirely
if engine_config.command().is_some() {
return Ok(String::new());
Expand All @@ -511,8 +514,9 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
.version()
.unwrap_or(COPILOT_CLI_VERSION);

// Validate version to prevent NuGet argument injection — the version string
// is embedded directly into NuGet command arguments.
// Validate version to prevent injection — this value is used in NuGet
// command arguments for 1ES and in GitHub Releases URL construction for
// non-1ES targets.
if !is_valid_version(version) {
anyhow::bail!(
"engine.version '{}' contains invalid characters. \
Expand All @@ -521,16 +525,17 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
);
}

// "latest" means "install the newest available version" — NuGet doesn't
// recognise "latest" as a version string; omitting -Version installs the newest.
let version_arg = if version == "latest" {
String::new()
} else {
format!("-Version {version} ")
};
if *target == CompileTarget::OneES {
// "latest" means "install the newest available version" — NuGet doesn't
// recognise "latest" as a version string; omitting -Version installs the newest.
let version_arg = if version == "latest" {
String::new()
} else {
format!("-Version {version} ")
};

Ok(format!(
"\
return Ok(format!(
"\
- task: NuGetAuthenticate@1
displayName: \"Authenticate NuGet Feed\"

Expand All @@ -551,6 +556,82 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
chmod +x /tmp/awf-tools/copilot
displayName: \"Add copilot to PATH\"

- bash: |
copilot --version
copilot -h
displayName: \"Output copilot version\""
));
}

if version == "latest" {
return copilot_install_from_github_release(
&format!("{COPILOT_CLI_RELEASES_BASE}/latest/download"),
"Install Copilot CLI (latest)",
);
}

let version_tag = normalize_version_tag(version);
let base_url = format!("{COPILOT_CLI_RELEASES_BASE}/download/{version_tag}");
copilot_install_from_github_release(
&base_url,
&format!("Install Copilot CLI ({version_tag})"),
)
}

fn normalize_version_tag(version: &str) -> String {
if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
}
}

fn copilot_install_from_github_release(base_url: &str, display_name: &str) -> Result<String> {
Ok(format!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot depending on version (i.e. latest/specific version), please have two separate steps. This bash script is unweildy

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5c51288 (plus cleanup in 65f6e60): non-1es install generation is now split into separate compile-time paths for latest vs pinned versions, so the runtime bash body no longer branches on version.

"\
- bash: |
set -euo pipefail
TARBALL_NAME=\"copilot-linux-x64.tar.gz\"
BASE_URL=\"{base_url}\"
TARBALL_URL=\"$BASE_URL/$TARBALL_NAME\"
CHECKSUMS_URL=\"$BASE_URL/SHA256SUMS.txt\"
TOOLS_DIR=\"$(Agent.TempDirectory)/tools\"
TEMP_DIR=\"$(mktemp -d)\"
trap 'rm -rf \"$TEMP_DIR\"' EXIT
mkdir -p \"$TOOLS_DIR\" /tmp/awf-tools

curl -fsSL --retry 3 --retry-delay 5 -o \"$TEMP_DIR/SHA256SUMS.txt\" \"$CHECKSUMS_URL\"
curl -fsSL --retry 3 --retry-delay 5 -o \"$TEMP_DIR/$TARBALL_NAME\" \"$TARBALL_URL\"

EXPECTED_CHECKSUM=$(awk -v fname=\"$TARBALL_NAME\" '$2 == fname {{print $1; exit}}' \"$TEMP_DIR/SHA256SUMS.txt\" | tr 'A-F' 'a-f')
if [ -z \"$EXPECTED_CHECKSUM\" ]; then
echo \"ERROR: failed to resolve expected checksum for $TARBALL_NAME\"
exit 1
fi

if command -v sha256sum > /dev/null 2>&1; then
ACTUAL_CHECKSUM=$(sha256sum \"$TEMP_DIR/$TARBALL_NAME\" | awk '{{print $1}}' | tr 'A-F' 'a-f')
elif command -v shasum > /dev/null 2>&1; then
ACTUAL_CHECKSUM=$(shasum -a 256 \"$TEMP_DIR/$TARBALL_NAME\" | awk '{{print $1}}' | tr 'A-F' 'a-f')
else
echo \"ERROR: neither sha256sum nor shasum is available\"
exit 1
fi

if [ \"$EXPECTED_CHECKSUM\" != \"$ACTUAL_CHECKSUM\" ]; then
echo \"ERROR: checksum verification failed\"
echo \"Expected: $EXPECTED_CHECKSUM\"
echo \"Actual: $ACTUAL_CHECKSUM\"
exit 1
fi

tar -xz -C \"$TOOLS_DIR\" -f \"$TEMP_DIR/$TARBALL_NAME\"
ls -la \"$TOOLS_DIR\"
echo \"##vso[task.prependpath]$TOOLS_DIR\"
cp \"$TOOLS_DIR/copilot\" /tmp/awf-tools/copilot
chmod +x /tmp/awf-tools/copilot
displayName: \"{display_name}\"

- bash: |
copilot --version
copilot -h
Expand Down Expand Up @@ -585,7 +666,7 @@ fn copilot_invocation(

#[cfg(test)]
mod tests {
use super::{get_engine, Engine};
use super::{get_engine, normalize_version_tag, Engine};
use crate::compile::{extensions::collect_extensions, parse_markdown};

#[test]
Expand Down Expand Up @@ -922,7 +1003,7 @@ mod tests {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: '1.0.0 -Source https://evil.com'\n---\n",
).unwrap();
let result = Engine::Copilot.install_steps(&fm.engine);
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid characters"));
}
Expand All @@ -932,7 +1013,7 @@ mod tests {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: \"1.0.0'\"\n---\n",
).unwrap();
let result = Engine::Copilot.install_steps(&fm.engine);
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target);
assert!(result.is_err());
}

Expand All @@ -941,19 +1022,57 @@ mod tests {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: '1.0.34'\n---\n",
).unwrap();
let result = Engine::Copilot.install_steps(&fm.engine).unwrap();
assert!(result.contains("-Version 1.0.34"));
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
assert!(result.contains("releases/download/v1.0.34"));
assert!(result.contains("Install Copilot CLI (v1.0.34)"));
}

#[test]
fn engine_version_accepts_valid_with_v_prefix() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: 'v1.0.34'\n---\n",
).unwrap();
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
assert!(result.contains("releases/download/v1.0.34"));
assert!(result.contains("Install Copilot CLI (v1.0.34)"));
}

#[test]
fn engine_version_accepts_latest() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: latest\n---\n",
).unwrap();
let result = Engine::Copilot.install_steps(&fm.engine).unwrap();
// "latest" omits -Version entirely so NuGet installs the newest available
assert!(!result.contains("-Version"), "should not contain -Version flag for 'latest'");
assert!(result.contains("-OutputDirectory"), "should still contain other NuGet args");
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
assert!(result.contains("releases/latest/download"), "latest should resolve via latest release URL");
assert!(result.contains("Install Copilot CLI (latest)"));
}

#[test]
fn engine_install_onees_latest_omits_version_argument() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\ntarget: 1es\nengine:\n id: copilot\n version: latest\n---\n",
).unwrap();
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
assert!(result.contains("NuGetCommand@2"));
assert!(result.contains("Guardian1ESPTUpstreamOrgFeed"));
assert!(!result.contains("-Version latest"));
}

#[test]
fn engine_install_onees_uses_nuget_feed() {
let (fm, _) = parse_markdown(
"---\nname: test\ndescription: test\ntarget: 1es\nengine:\n id: copilot\n version: '1.0.34'\n---\n",
).unwrap();
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
assert!(result.contains("NuGetCommand@2"));
assert!(result.contains("Guardian1ESPTUpstreamOrgFeed"));
assert!(result.contains("-Version 1.0.34"));
}

#[test]
fn normalize_version_tag_does_not_double_prefix_v() {
assert_eq!(normalize_version_tag("v1.0.34"), "v1.0.34");
assert_eq!(normalize_version_tag("1.0.34"), "v1.0.34");
}

// ─── engine.env empty key test ────────────────────────────────────────
Expand Down