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
5 changes: 3 additions & 2 deletions docs/template-markers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions site/src/content/docs/reference/template-markers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}`
Expand Down
2 changes: 1 addition & 1 deletion src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
117 changes: 105 additions & 12 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
///
/// `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<String> {
match self {
Engine::Copilot => copilot_install_steps(engine_config, target),
Engine::Copilot => copilot_install_steps(engine_config, target, ado_org),
}
}

Expand Down Expand Up @@ -504,7 +508,12 @@ fn copilot_env(engine_config: &EngineConfig) -> Result<String> {
/// - 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> {
///
/// `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<String> {
// Custom binary path → skip NuGet install entirely
if engine_config.command().is_some() {
return Ok(String::new());
Expand Down Expand Up @@ -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\"
Expand Down Expand Up @@ -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"));
}
Expand All @@ -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());
}

Expand All @@ -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)"));
}
Expand All @@ -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)"));
}
Expand All @@ -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)"));
}
Expand All @@ -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"));
}

Expand All @@ -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");
Expand Down