diff --git a/docs/template-markers.md b/docs/template-markers.md index b18fa986..2ac44d55 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -163,9 +163,10 @@ job- and stage-level templates don't emit a top-level pipeline name. Should be replaced with engine-specific pipeline steps to install the engine binary. Generated by `Engine::install_steps()`. The install strategy is **target-aware**: -**For `target: 1es`** — authenticates with an internal Azure Artifacts NuGet feed and installs the package: +**For `target: 1es`** — authenticates with the Azure Artifacts NuGet feed for the user's ADO organization and installs the package: +- Optional bash step to resolve the ADO org at runtime (emitted only when the org cannot be inferred at compile time from the git remote): extracts the organization name from `$(System.CollectionUri)` and stores it in the `AW_ADO_ORG` pipeline variable. - `NuGetAuthenticate@1` task -- `NuGetCommand@2` task to install `Microsoft.Copilot.CLI.linux-x64` from `pkgs.dev.azure.com/msazuresphere/_packaging/Guardian1ESPTUpstreamOrgFeed` (uses `engine.version` if set, otherwise `COPILOT_CLI_VERSION` constant; omits `-Version` flag when `"latest"`) +- `NuGetCommand@2` task to install `Microsoft.Copilot.CLI.linux-x64` from `pkgs.dev.azure.com/{org}/_packaging/Guardian1ESPTUpstreamOrgFeed`, where `{org}` is the ADO organization inferred at compile time (e.g. `contoso`) or the runtime variable `$(AW_ADO_ORG)` when compile-time inference is unavailable. Uses `engine.version` if set, otherwise `COPILOT_CLI_VERSION` constant; omits `-Version` flag when `"latest"`. - Bash step to copy binary to `/tmp/awf-tools/copilot` - Bash step to verify installation diff --git a/site/src/content/docs/reference/template-markers.mdx b/site/src/content/docs/reference/template-markers.mdx index c4087339..f2179aaa 100644 --- a/site/src/content/docs/reference/template-markers.mdx +++ b/site/src/content/docs/reference/template-markers.mdx @@ -102,12 +102,19 @@ Should be replaced with the human-readable name from the front matter (e.g., "Da ## `{{ engine_install_steps }}` -Should be replaced with engine-specific pipeline steps to install the engine binary. Generated by `Engine::install_steps()`. For Copilot, this produces: +Should be replaced with engine-specific pipeline steps to install the engine binary. Generated by `Engine::install_steps()`. For Copilot, the install strategy is **target-aware**: + +**For `target: 1es`** — authenticates with the Azure Artifacts NuGet feed for the user's ADO organization: +- Optional bash step "Resolve ADO organization": emitted only when the org cannot be inferred at compile time; extracts the organization name from `$(System.CollectionUri)` and stores it as the `AW_ADO_ORG` pipeline variable. - `NuGetAuthenticate@1` task -- `NuGetCommand@2` task to install `Microsoft.Copilot.CLI.linux-x64` (uses `engine.version` if set, otherwise `COPILOT_CLI_VERSION` constant) +- `NuGetCommand@2` task to install `Microsoft.Copilot.CLI.linux-x64` from `pkgs.dev.azure.com/{org}/_packaging/Guardian1ESPTUpstreamOrgFeed` (uses `engine.version` if set, otherwise `COPILOT_CLI_VERSION` constant; omits `-Version` flag when `"latest"`) - Bash step to copy binary to `/tmp/awf-tools/copilot` - Bash step to verify installation +**For all other targets** — downloads from GitHub Releases: +- Bash step to download and verify the binary +- Bash step to verify installation + Returns empty when `engine.command` is set (user provides own binary). ## `{{ engine_run }}` diff --git a/src/compile/common.rs b/src/compile/common.rs index 235a7f9e..36b923a8 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2961,7 +2961,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, &front_matter.target)?; + let engine_install_steps = ctx.engine.install_steps(&front_matter.engine, &front_matter.target, ctx.ado_org())?; // 5. Compute workspace, working directory, triggers let effective_workspace = compute_effective_workspace( diff --git a/src/engine.rs b/src/engine.rs index d5ccffbb..ddf606df 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -135,9 +135,13 @@ 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, target: &CompileTarget) -> Result { + /// + /// `ado_org` is the ADO organization name inferred from the git remote at + /// compile time. For 1ES targets it is embedded directly into the NuGet + /// feed URL; when `None` a runtime extraction step is emitted instead. + pub fn install_steps(&self, engine_config: &EngineConfig, target: &CompileTarget, ado_org: Option<&str>) -> Result { match self { - Engine::Copilot => copilot_install_steps(engine_config, target), + Engine::Copilot => copilot_install_steps(engine_config, target, ado_org), } } @@ -504,7 +508,12 @@ fn copilot_env(engine_config: &EngineConfig) -> Result { /// - 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 { +/// +/// `ado_org` is the ADO organization name inferred from the git remote at +/// compile time. For 1ES it is used to construct the NuGet feed URL; when +/// `None` a runtime extraction step is emitted that derives the org from +/// `$(System.CollectionUri)`. +fn copilot_install_steps(engine_config: &EngineConfig, target: &CompileTarget, ado_org: Option<&str>) -> Result { // Custom binary path → skip NuGet install entirely if engine_config.command().is_some() { return Ok(String::new()); @@ -534,16 +543,63 @@ fn copilot_install_steps(engine_config: &EngineConfig, target: &CompileTarget) - format!("-Version {version} ") }; + // Build the NuGet feed URL using the org name. When the org is known + // at compile time (inferred from the git remote) it is embedded + // directly. When it is not available a preceding bash step extracts + // the org at runtime from the $(System.CollectionUri) ADO variable and + // exposes it as $(AW_ADO_ORG) for use in the NuGetCommand arguments. + let (org_resolve_step, nuget_org) = match ado_org { + Some(org) => { + // Validate the org name against ADO organization naming rules to + // prevent injection. ADO org names are composed of ASCII + // alphanumerics and hyphens only (no dots, no underscores). + let org_valid = !org.is_empty() + && org.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); + if !org_valid { + anyhow::bail!( + "ADO organization '{}' contains invalid characters. \ + Only ASCII alphanumerics and '-' are allowed.", + org + ); + } + (String::new(), org.to_string()) + } + None => { + // Emit a bash step that extracts the org name from the ADO + // system variable $(System.CollectionUri) at runtime and + // stores it as a pipeline variable. + // + // $(System.CollectionUri) is expanded by ADO before bash runs + // (e.g. "https://dev.azure.com/myorg/"); the parameter + // expansions strip the prefix and trailing slash to yield just + // the org name ("myorg"). + let step = "\ +- bash: | + set -eo pipefail + # $(System.CollectionUri) is expanded by ADO before bash runs, + # e.g. \"https://dev.azure.com/myorg/\". + _COLLECTION_URI=\"$(System.CollectionUri)\" + _ORG=\"${_COLLECTION_URI#https://dev.azure.com/}\" + _ORG=\"${_ORG%/}\" + echo \"##vso[task.setvariable variable=AW_ADO_ORG]$_ORG\" + displayName: \"Resolve ADO organization\" + +" + .to_string(); + (step, "$(AW_ADO_ORG)".to_string()) + } + }; + return Ok(format!( "\ -- task: NuGetAuthenticate@1 +{org_resolve_step}- 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_arg}-OutputDirectory $(Agent.TempDirectory)/tools -ExcludeVersion -NonInteractive' + arguments: 'install Microsoft.Copilot.CLI.linux-x64 -Source \"https://pkgs.dev.azure.com/{nuget_org}/_packaging/Guardian1ESPTUpstreamOrgFeed/nuget/v3/index.json\" {version_arg}-OutputDirectory $(Agent.TempDirectory)/tools -ExcludeVersion -NonInteractive' - bash: | ls -la \"$(Agent.TempDirectory)/tools\" @@ -1003,7 +1059,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, &fm.target); + let result = Engine::Copilot.install_steps(&fm.engine, &fm.target, None); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("invalid characters")); } @@ -1013,7 +1069,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, &fm.target); + let result = Engine::Copilot.install_steps(&fm.engine, &fm.target, None); assert!(result.is_err()); } @@ -1022,7 +1078,7 @@ 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, &fm.target).unwrap(); + let result = Engine::Copilot.install_steps(&fm.engine, &fm.target, None).unwrap(); assert!(result.contains("releases/download/v1.0.34")); assert!(result.contains("Install Copilot CLI (v1.0.34)")); } @@ -1032,7 +1088,7 @@ mod tests { 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(); + let result = Engine::Copilot.install_steps(&fm.engine, &fm.target, None).unwrap(); assert!(result.contains("releases/download/v1.0.34")); assert!(result.contains("Install Copilot CLI (v1.0.34)")); } @@ -1042,7 +1098,7 @@ mod tests { 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, &fm.target).unwrap(); + let result = Engine::Copilot.install_steps(&fm.engine, &fm.target, None).unwrap(); assert!(result.contains("releases/latest/download"), "latest should resolve via latest release URL"); assert!(result.contains("Install Copilot CLI (latest)")); } @@ -1052,9 +1108,10 @@ mod tests { 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(); + let result = Engine::Copilot.install_steps(&fm.engine, &fm.target, Some("myorg")).unwrap(); assert!(result.contains("NuGetCommand@2")); assert!(result.contains("Guardian1ESPTUpstreamOrgFeed")); + assert!(result.contains("pkgs.dev.azure.com/myorg/")); assert!(!result.contains("-Version latest")); } @@ -1063,12 +1120,48 @@ mod tests { 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(); + let result = Engine::Copilot.install_steps(&fm.engine, &fm.target, Some("myorg")).unwrap(); assert!(result.contains("NuGetCommand@2")); assert!(result.contains("Guardian1ESPTUpstreamOrgFeed")); + assert!(result.contains("pkgs.dev.azure.com/myorg/")); assert!(result.contains("-Version 1.0.34")); } + #[test] + fn engine_install_onees_uses_user_org_not_msazuresphere() { + 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, Some("contoso")).unwrap(); + assert!(result.contains("pkgs.dev.azure.com/contoso/")); + assert!(!result.contains("msazuresphere")); + } + + #[test] + fn engine_install_onees_runtime_fallback_when_org_unknown() { + 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, None).unwrap(); + assert!(result.contains("NuGetCommand@2")); + assert!(result.contains("Guardian1ESPTUpstreamOrgFeed")); + // Runtime fallback: org extracted from $(System.CollectionUri) + assert!(result.contains("$(AW_ADO_ORG)")); + assert!(result.contains("$(System.CollectionUri)")); + assert!(result.contains("Resolve ADO organization")); + assert!(!result.contains("msazuresphere")); + } + + #[test] + fn engine_install_onees_rejects_invalid_org() { + 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, Some("evil; rm -rf /")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid characters")); + } + #[test] fn normalize_version_tag_does_not_double_prefix_v() { assert_eq!(normalize_version_tag("v1.0.34"), "v1.0.34");