11use anyhow:: Result ;
22
33use crate :: compile:: extensions:: { CompilerExtension , Extension } ;
4- use crate :: compile:: types:: { EngineConfig , FrontMatter , McpConfig } ;
4+ use crate :: compile:: types:: { CompileTarget , EngineConfig , FrontMatter , McpConfig } ;
55use crate :: validate:: {
66 contains_ado_expression, contains_newline, contains_pipeline_command, is_valid_arg,
77 is_valid_command_path, is_valid_env_var_name, is_valid_hostname, is_valid_identifier,
@@ -40,9 +40,10 @@ pub const BLOCKED_ENV_KEYS: &[&str] = &[
4040/// Default model used by the Copilot engine when no model is specified in front matter.
4141pub const DEFAULT_COPILOT_MODEL : & str = "claude-opus-4.7" ;
4242
43- /// Default pinned version of the Copilot CLI NuGet package .
43+ /// Default pinned version of the Copilot CLI.
4444/// Override per-agent via `engine: { id: copilot, version: "1.0.35" }` in front matter.
4545pub const COPILOT_CLI_VERSION : & str = "1.0.47" ;
46+ const COPILOT_CLI_RELEASES_BASE : & str = "https://github.com/github/copilot-cli/releases" ;
4647
4748/// Resolved engine — enum dispatch over supported engine identifiers.
4849///
@@ -134,9 +135,9 @@ impl Engine {
134135 /// Uses `engine_config.version()` if set in front matter, otherwise falls back
135136 /// to the pinned `COPILOT_CLI_VERSION` constant. Returns an empty string when
136137 /// `engine.command` is set (the user provides their own binary).
137- pub fn install_steps ( & self , engine_config : & EngineConfig ) -> Result < String > {
138+ pub fn install_steps ( & self , engine_config : & EngineConfig , target : & CompileTarget ) -> Result < String > {
138139 match self {
139- Engine :: Copilot => copilot_install_steps ( engine_config) ,
140+ Engine :: Copilot => copilot_install_steps ( engine_config, target ) ,
140141 }
141142 }
142143
@@ -498,10 +499,12 @@ fn copilot_env(engine_config: &EngineConfig) -> Result<String> {
498499
499500/// Generate Copilot CLI install steps for Azure DevOps pipelines.
500501///
501- /// Produces the YAML block that authenticates with NuGet, installs the
502- /// `Microsoft.Copilot.CLI.linux-x64` package, copies the binary to
503- /// `/tmp/awf-tools/copilot`, and verifies the install.
504- fn copilot_install_steps ( engine_config : & EngineConfig ) -> Result < String > {
502+ /// Produces target-specific YAML:
503+ /// - 1ES: authenticate with NuGet and install `Microsoft.Copilot.CLI.linux-x64`.
504+ /// - Non-1ES: download Copilot CLI from GitHub Releases and verify SHA256.
505+ ///
506+ /// Both paths stage the binary at `/tmp/awf-tools/copilot`.
507+ fn copilot_install_steps ( engine_config : & EngineConfig , target : & CompileTarget ) -> Result < String > {
505508 // Custom binary path → skip NuGet install entirely
506509 if engine_config. command ( ) . is_some ( ) {
507510 return Ok ( String :: new ( ) ) ;
@@ -511,8 +514,9 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
511514 . version ( )
512515 . unwrap_or ( COPILOT_CLI_VERSION ) ;
513516
514- // Validate version to prevent NuGet argument injection — the version string
515- // is embedded directly into NuGet command arguments.
517+ // Validate version to prevent injection — this value is used in NuGet
518+ // command arguments for 1ES and in GitHub Releases URL construction for
519+ // non-1ES targets.
516520 if !is_valid_version ( version) {
517521 anyhow:: bail!(
518522 "engine.version '{}' contains invalid characters. \
@@ -521,16 +525,17 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
521525 ) ;
522526 }
523527
524- // "latest" means "install the newest available version" — NuGet doesn't
525- // recognise "latest" as a version string; omitting -Version installs the newest.
526- let version_arg = if version == "latest" {
527- String :: new ( )
528- } else {
529- format ! ( "-Version {version} " )
530- } ;
528+ if * target == CompileTarget :: OneES {
529+ // "latest" means "install the newest available version" — NuGet doesn't
530+ // recognise "latest" as a version string; omitting -Version installs the newest.
531+ let version_arg = if version == "latest" {
532+ String :: new ( )
533+ } else {
534+ format ! ( "-Version {version} " )
535+ } ;
531536
532- Ok ( format ! (
533- "\
537+ return Ok ( format ! (
538+ "\
534539 - task: NuGetAuthenticate@1
535540 displayName: \" Authenticate NuGet Feed\"
536541
@@ -551,6 +556,82 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
551556 chmod +x /tmp/awf-tools/copilot
552557 displayName: \" Add copilot to PATH\"
553558
559+ - bash: |
560+ copilot --version
561+ copilot -h
562+ displayName: \" Output copilot version\" "
563+ ) ) ;
564+ }
565+
566+ if version == "latest" {
567+ return copilot_install_from_github_release (
568+ & format ! ( "{COPILOT_CLI_RELEASES_BASE}/latest/download" ) ,
569+ "Install Copilot CLI (latest)" ,
570+ ) ;
571+ }
572+
573+ let version_tag = normalize_version_tag ( version) ;
574+ let base_url = format ! ( "{COPILOT_CLI_RELEASES_BASE}/download/{version_tag}" ) ;
575+ copilot_install_from_github_release (
576+ & base_url,
577+ & format ! ( "Install Copilot CLI ({version_tag})" ) ,
578+ )
579+ }
580+
581+ fn normalize_version_tag ( version : & str ) -> String {
582+ if version. starts_with ( 'v' ) {
583+ version. to_string ( )
584+ } else {
585+ format ! ( "v{version}" )
586+ }
587+ }
588+
589+ fn copilot_install_from_github_release ( base_url : & str , display_name : & str ) -> Result < String > {
590+ Ok ( format ! (
591+ "\
592+ - bash: |
593+ set -euo pipefail
594+ TARBALL_NAME=\" copilot-linux-x64.tar.gz\"
595+ BASE_URL=\" {base_url}\"
596+ TARBALL_URL=\" $BASE_URL/$TARBALL_NAME\"
597+ CHECKSUMS_URL=\" $BASE_URL/SHA256SUMS.txt\"
598+ TOOLS_DIR=\" $(Agent.TempDirectory)/tools\"
599+ TEMP_DIR=\" $(mktemp -d)\"
600+ trap 'rm -rf \" $TEMP_DIR\" ' EXIT
601+ mkdir -p \" $TOOLS_DIR\" /tmp/awf-tools
602+
603+ curl -fsSL --retry 3 --retry-delay 5 -o \" $TEMP_DIR/SHA256SUMS.txt\" \" $CHECKSUMS_URL\"
604+ curl -fsSL --retry 3 --retry-delay 5 -o \" $TEMP_DIR/$TARBALL_NAME\" \" $TARBALL_URL\"
605+
606+ EXPECTED_CHECKSUM=$(awk -v fname=\" $TARBALL_NAME\" '$2 == fname {{print $1; exit}}' \" $TEMP_DIR/SHA256SUMS.txt\" | tr 'A-F' 'a-f')
607+ if [ -z \" $EXPECTED_CHECKSUM\" ]; then
608+ echo \" ERROR: failed to resolve expected checksum for $TARBALL_NAME\"
609+ exit 1
610+ fi
611+
612+ if command -v sha256sum > /dev/null 2>&1; then
613+ ACTUAL_CHECKSUM=$(sha256sum \" $TEMP_DIR/$TARBALL_NAME\" | awk '{{print $1}}' | tr 'A-F' 'a-f')
614+ elif command -v shasum > /dev/null 2>&1; then
615+ ACTUAL_CHECKSUM=$(shasum -a 256 \" $TEMP_DIR/$TARBALL_NAME\" | awk '{{print $1}}' | tr 'A-F' 'a-f')
616+ else
617+ echo \" ERROR: neither sha256sum nor shasum is available\"
618+ exit 1
619+ fi
620+
621+ if [ \" $EXPECTED_CHECKSUM\" != \" $ACTUAL_CHECKSUM\" ]; then
622+ echo \" ERROR: checksum verification failed\"
623+ echo \" Expected: $EXPECTED_CHECKSUM\"
624+ echo \" Actual: $ACTUAL_CHECKSUM\"
625+ exit 1
626+ fi
627+
628+ tar -xz -C \" $TOOLS_DIR\" -f \" $TEMP_DIR/$TARBALL_NAME\"
629+ ls -la \" $TOOLS_DIR\"
630+ echo \" ##vso[task.prependpath]$TOOLS_DIR\"
631+ cp \" $TOOLS_DIR/copilot\" /tmp/awf-tools/copilot
632+ chmod +x /tmp/awf-tools/copilot
633+ displayName: \" {display_name}\"
634+
554635- bash: |
555636 copilot --version
556637 copilot -h
@@ -585,7 +666,7 @@ fn copilot_invocation(
585666
586667#[ cfg( test) ]
587668mod tests {
588- use super :: { get_engine, Engine } ;
669+ use super :: { get_engine, normalize_version_tag , Engine } ;
589670 use crate :: compile:: { extensions:: collect_extensions, parse_markdown} ;
590671
591672 #[ test]
@@ -922,7 +1003,7 @@ mod tests {
9221003 let ( fm, _) = parse_markdown (
9231004 "---\n name: test\n description: test\n engine:\n id: copilot\n version: '1.0.0 -Source https://evil.com'\n ---\n " ,
9241005 ) . unwrap ( ) ;
925- let result = Engine :: Copilot . install_steps ( & fm. engine ) ;
1006+ let result = Engine :: Copilot . install_steps ( & fm. engine , & fm . target ) ;
9261007 assert ! ( result. is_err( ) ) ;
9271008 assert ! ( result. unwrap_err( ) . to_string( ) . contains( "invalid characters" ) ) ;
9281009 }
@@ -932,7 +1013,7 @@ mod tests {
9321013 let ( fm, _) = parse_markdown (
9331014 "---\n name: test\n description: test\n engine:\n id: copilot\n version: \" 1.0.0'\" \n ---\n " ,
9341015 ) . unwrap ( ) ;
935- let result = Engine :: Copilot . install_steps ( & fm. engine ) ;
1016+ let result = Engine :: Copilot . install_steps ( & fm. engine , & fm . target ) ;
9361017 assert ! ( result. is_err( ) ) ;
9371018 }
9381019
@@ -941,19 +1022,57 @@ mod tests {
9411022 let ( fm, _) = parse_markdown (
9421023 "---\n name: test\n description: test\n engine:\n id: copilot\n version: '1.0.34'\n ---\n " ,
9431024 ) . unwrap ( ) ;
944- let result = Engine :: Copilot . install_steps ( & fm. engine ) . unwrap ( ) ;
945- assert ! ( result. contains( "-Version 1.0.34" ) ) ;
1025+ let result = Engine :: Copilot . install_steps ( & fm. engine , & fm. target ) . unwrap ( ) ;
1026+ assert ! ( result. contains( "releases/download/v1.0.34" ) ) ;
1027+ assert ! ( result. contains( "Install Copilot CLI (v1.0.34)" ) ) ;
1028+ }
1029+
1030+ #[ test]
1031+ fn engine_version_accepts_valid_with_v_prefix ( ) {
1032+ let ( fm, _) = parse_markdown (
1033+ "---\n name: test\n description: test\n engine:\n id: copilot\n version: 'v1.0.34'\n ---\n " ,
1034+ ) . unwrap ( ) ;
1035+ let result = Engine :: Copilot . install_steps ( & fm. engine , & fm. target ) . unwrap ( ) ;
1036+ assert ! ( result. contains( "releases/download/v1.0.34" ) ) ;
1037+ assert ! ( result. contains( "Install Copilot CLI (v1.0.34)" ) ) ;
9461038 }
9471039
9481040 #[ test]
9491041 fn engine_version_accepts_latest ( ) {
9501042 let ( fm, _) = parse_markdown (
9511043 "---\n name: test\n description: test\n engine:\n id: copilot\n version: latest\n ---\n " ,
9521044 ) . unwrap ( ) ;
953- let result = Engine :: Copilot . install_steps ( & fm. engine ) . unwrap ( ) ;
954- // "latest" omits -Version entirely so NuGet installs the newest available
955- assert ! ( !result. contains( "-Version" ) , "should not contain -Version flag for 'latest'" ) ;
956- assert ! ( result. contains( "-OutputDirectory" ) , "should still contain other NuGet args" ) ;
1045+ let result = Engine :: Copilot . install_steps ( & fm. engine , & fm. target ) . unwrap ( ) ;
1046+ assert ! ( result. contains( "releases/latest/download" ) , "latest should resolve via latest release URL" ) ;
1047+ assert ! ( result. contains( "Install Copilot CLI (latest)" ) ) ;
1048+ }
1049+
1050+ #[ test]
1051+ fn engine_install_onees_latest_omits_version_argument ( ) {
1052+ let ( fm, _) = parse_markdown (
1053+ "---\n name: test\n description: test\n target: 1es\n engine:\n id: copilot\n version: latest\n ---\n " ,
1054+ ) . unwrap ( ) ;
1055+ let result = Engine :: Copilot . install_steps ( & fm. engine , & fm. target ) . unwrap ( ) ;
1056+ assert ! ( result. contains( "NuGetCommand@2" ) ) ;
1057+ assert ! ( result. contains( "Guardian1ESPTUpstreamOrgFeed" ) ) ;
1058+ assert ! ( !result. contains( "-Version latest" ) ) ;
1059+ }
1060+
1061+ #[ test]
1062+ fn engine_install_onees_uses_nuget_feed ( ) {
1063+ let ( fm, _) = parse_markdown (
1064+ "---\n name: test\n description: test\n target: 1es\n engine:\n id: copilot\n version: '1.0.34'\n ---\n " ,
1065+ ) . unwrap ( ) ;
1066+ let result = Engine :: Copilot . install_steps ( & fm. engine , & fm. target ) . unwrap ( ) ;
1067+ assert ! ( result. contains( "NuGetCommand@2" ) ) ;
1068+ assert ! ( result. contains( "Guardian1ESPTUpstreamOrgFeed" ) ) ;
1069+ assert ! ( result. contains( "-Version 1.0.34" ) ) ;
1070+ }
1071+
1072+ #[ test]
1073+ fn normalize_version_tag_does_not_double_prefix_v ( ) {
1074+ assert_eq ! ( normalize_version_tag( "v1.0.34" ) , "v1.0.34" ) ;
1075+ assert_eq ! ( normalize_version_tag( "1.0.34" ) , "v1.0.34" ) ;
9571076 }
9581077
9591078 // ─── engine.env empty key test ────────────────────────────────────────
0 commit comments