From 15c678ea0e843f6e5497fd749cfdd5be0ed4f926 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 2 Jun 2026 22:18:30 +0100 Subject: [PATCH] fix(safe-outputs): upload-pipeline-artifact uses $(System.AccessToken) (#471) Closes #471. Root cause ---------- Build 610504 failed at PUT /_apis/resources/Containers/{id} with HTTP 404 ContainerWriteAccessDeniedException. The ADO File Container ACL is keyed on the build's job-plan identity (Project Build Service account). When permissions.write: is set, Stage 3 overrides SYSTEM_ACCESSTOKEN with $(SC_WRITE_TOKEN), an ARM SPN bearer minted via AzureCLI@2. ARM SPNs are never in the File Container ACL regardless of project-level RBAC, so the PUT is rejected with 404. Phase 1 - surgical fix ---------------------- * New SYSTEM_TOKEN_SAFE_OUTPUTS category containing UploadPipelineArtifactResult. Handlers in this category are excluded from WRITE_REQUIRING_SAFE_OUTPUTS and from the validate_write_permissions gating. * generate_executor_ado_env now emits ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken) alongside the existing SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN) whenever any SYSTEM_TOKEN_SAFE_OUTPUTS handler is configured. * ExecutionContext gains a system_access_token field, populated from ADO_SYSTEM_ACCESS_TOKEN. * upload_pipeline_artifact reads system_access_token first and falls back to access_token. Every other handler is bit-identical. Orthogonal cleanup ------------------ * Removed build_id and allowed-build-ids from upload-pipeline-artifact. The File Container ACL constraint makes cross-build publishing impossible; the fields never worked and silently rejected requests at runtime. Forward look - Phase 2 ---------------------- This PR is the first half of a larger gh-aw-aligned redesign. Phase 2 will introduce per-handler service-connection: declarations, a block-level safe-outputs.service-connection: default, and a codemod that auto-migrates the top-level permissions: schema. The SYSTEM_TOKEN_SAFE_OUTPUTS category introduced here becomes the mechanism for "use the Build Service identity" handlers in that redesign. Validation ---------- * cargo build * cargo test (1758 unit + integration, zero failures) * cargo clippy --all-targets --all-features (zero warnings) * tests/safe-outputs/upload-pipeline-artifact.lock.yml regenerated; the env block contains both SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN) and ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/network.md | 7 +- docs/safe-outputs.md | 84 ++-- docs/template-markers.md | 7 +- src/compile/common.rs | 94 +++- src/mcp.rs | 18 +- src/safeoutputs/create_pull_request.rs | 1 + src/safeoutputs/mod.rs | 62 ++- src/safeoutputs/result.rs | 18 + src/safeoutputs/upload_build_attachment.rs | 1 + src/safeoutputs/upload_pipeline_artifact.rs | 437 ++++++++++-------- .../upload-pipeline-artifact.lock.yml | 106 +++-- 11 files changed, 532 insertions(+), 303 deletions(-) diff --git a/docs/network.md b/docs/network.md index 923a73bc..93ebd79b 100644 --- a/docs/network.md +++ b/docs/network.md @@ -128,12 +128,13 @@ permissions: ### Security Model - **`permissions.read`**: Mints a read-only ADO-scoped token given to the agent inside the AWF sandbox (Stage 1). The agent can query ADO APIs but cannot write. -- **`permissions.write`**: Mints a write-capable ADO-scoped token used **only** by the executor in Stage 3 (`SafeOutputs` job). This token is never exposed to the agent. -- **Both omitted**: No ADO tokens are passed anywhere. The agent has no ADO API access. +- **`permissions.write`**: Mints a write-capable ADO-scoped token used **only** by the executor in Stage 3 (`SafeOutputs` job). This token is never exposed to the agent. Consumed by most write-requiring safe outputs (`create-pull-request`, `create-work-item`, etc.). +- **`upload-pipeline-artifact` exception**: This safe output authenticates as the build's **own job-plan identity** (`$(System.AccessToken)`, via the `ADO_SYSTEM_ACCESS_TOKEN` env var) rather than the ARM SPN bearer from `permissions.write`. The ADO File Container API's ACL is keyed on the build's identity at container-create time and rejects SPN bearers (with `HTTP 404 ContainerWriteAccessDeniedException`) regardless of project-level RBAC. As a result, `upload-pipeline-artifact` does **not** require `permissions.write` to be set. +- **Both omitted**: No ARM-minted ADO tokens are passed anywhere. The agent has no ADO API access; safe outputs that need write access (other than `upload-pipeline-artifact`) cannot be configured. ### Compile-Time Validation -If write-requiring safe-outputs (`create-pull-request`, `create-work-item`) are configured but `permissions.write` is missing, compilation fails with a clear error message. +If write-requiring safe-outputs (`create-pull-request`, `create-work-item`, etc.) are configured but `permissions.write` is missing, compilation fails with a clear error message. `upload-pipeline-artifact` is exempt from this check — see the section above. ### Examples diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index a3e66459..3e12d5dd 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -538,30 +538,43 @@ artifacts instead. ### upload-pipeline-artifact Publishes a workspace file as an Azure DevOps **pipeline artifact** that appears -in the **Artifacts tab** of the build summary page. Uses the ADO build artifacts -REST API in two steps: +in the **Artifacts tab** of the build summary page of the **current pipeline +run**. Uses the ADO build artifacts REST API in two steps: -1. **Upload bytes** to the agent's own per-build file container (Azure DevOps +1. **Upload bytes** to the build's own per-build file container (Azure DevOps creates one container per build and exposes its ID via `BUILD_CONTAINERID`). -2. **Associate** the artifact record (`name = artifact_name`) with the target - build via `POST /{project}/_apis/build/builds/{effective_build_id}/artifacts`. - -**Omit `build_id` to target the current pipeline run** — the executor resolves -the build ID from the `BUILD_BUILDID` environment variable automatically. When -`build_id` is provided, the artifact record is published to that specific build -("cross-build publishing"). The artifact bytes still live in the agent's own -build container; only the record's pointer is associated with the target build. -This means cross-published artifacts share the agent build's retention — if the -agent's build is purged, the cross-referenced artifact stops being downloadable. -Cross-project publishing is not supported (the associate POST uses the current -pipeline's project). +2. **Associate** the artifact record (`name = artifact_name`) with the current + build via `POST /{project}/_apis/build/builds/{BUILD_BUILDID}/artifacts`. The tool stages the file during Stage 1 (MCP) by copying it into the safe-outputs directory; Stage 3 reads the staged copy and executes the two-step REST flow. +#### Authentication: always `$(System.AccessToken)` + +`upload-pipeline-artifact` is the only safe output that must authenticate as +the build's **own job-plan identity** (the Project Build Service account) — +exposed in the executor's env block as `ADO_SYSTEM_ACCESS_TOKEN` and sourced +from the built-in `$(System.AccessToken)` pipeline macro. It does **not** +honour `permissions.write:`, and configuring an ARM service connection for +the rest of the pipeline does not affect this tool. + +The reason is structural: the ADO File Container API rejects any other +identity with `HTTP 404 ContainerWriteAccessDeniedException`, regardless of +project-level RBAC. The container's ACL is keyed on the build's identity at +container-create time, and ARM-minted SPN bearers are never in that ACL. + +Consequence: **`upload-pipeline-artifact` does not require `permissions.write:` +to be set**. Listing it in `safe-outputs:` is sufficient. + +#### Current-build only + +Cross-build publishing is **not supported**. The associate POST is scoped to +the current pipeline's project, and the File Container ACL described above +prevents writing to any other build's container. The agent has no way to +target a different build. + **Agent parameters:** -- `build_id` *(optional)* - Target build ID. Omit to publish to the current pipeline run. Must be positive when specified. - `artifact_name` - Artifact name shown in the Artifacts tab (1–100 chars, alphanumeric / `-` / `_` / `.`, no leading `.`) - `file_path` - Relative path to the file in the workspace (no directory traversal) @@ -572,7 +585,6 @@ safe-outputs: max-file-size: 52428800 # Maximum file size in bytes (default: 50 MB) allowed-extensions: [] # Optional — restrict file types (e.g., [".png", ".pdf", ".log"]) allowed-artifact-names: [] # Optional — restrict names (suffix `*` = prefix match) - allowed-build-ids: [] # Optional — restrict target builds (skipped when targeting current build) name-prefix: "" # Optional — prepended to the agent-supplied artifact name require-unique-names: false # Optional — see "Reusing artifact names" below max: 3 # Maximum per run (default: 3) @@ -580,31 +592,31 @@ safe-outputs: **Reusing artifact names within one agent run:** By default, the same `artifact_name` may be reused across multiple -`upload-pipeline-artifact` calls in one run (e.g. publishing a `TriageSummary` -to many failing builds at once). The executor inserts a short hash suffix -(`{artifact_name}__{6 hex}`) into the **internal container folder name** so -the calls don't silently overwrite each other's bytes in the agent's shared -build container. The hash lives only in internal addressing — it does not -appear in the `record.name` your downstream consumers query for, in the web UI -"Download as zip" filename, or in the contents of files extracted by the -`DownloadBuildArtifacts@1` / `DownloadPipelineArtifact@2` tasks (all of which -strip the container folder prefix). +`upload-pipeline-artifact` calls in one run. The executor inserts a short hash +suffix (`{artifact_name}__{6 hex}`) into the **internal container folder +name** — derived from the file content hash — so two calls with the same +name but different content don't silently overwrite each other's bytes in +the build's file container. Identical content maps to the same folder (a +safe, idempotent PUT). The hash lives only in internal addressing — it does +not appear in the `record.name` your downstream consumers query for, in the +web UI "Download as zip" filename, or in the contents of files extracted by +the `DownloadBuildArtifacts@1` / `DownloadPipelineArtifact@2` tasks (all of +which strip the container folder prefix). Set `require-unique-names: true` to use a clean container folder -(`{artifact_name}` only, no suffix) and reject in-run reuse of -`(effective_build_id, artifact_name)` with a clear early error before any HTTP -call. Use this when you guarantee one artifact per name per run and want the -shortest possible internal addressing. +(`{artifact_name}` only, no suffix) and reject any in-run reuse of +`artifact_name` with a clear early error before any HTTP call. Use this when +you guarantee one artifact per name per run and want the shortest possible +internal addressing. -Two records with the same `name` on the **same** target build still collide at -the record level (ADO returns 409 from the associate call) regardless of this -setting; use distinct `artifact_name` values when targeting one build with -multiple uploads. +Two records with the same `name` on the current build still collide at the +record level (ADO returns 409 from the associate call) regardless of this +setting; use distinct `artifact_name` values for multiple uploads in one run. **Notes:** - Single-file only; directory uploads are not supported. -- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped — the current build is implicitly trusted. -- Requires `BUILD_CONTAINERID`, `BUILD_BUILDID`, and `SYSTEM_TEAMPROJECTID` (all set automatically inside an Azure DevOps pipeline job) and `vso.build_execute` scope on the executor's token (the existing write service connection provides this). +- Requires `BUILD_CONTAINERID`, `BUILD_BUILDID`, `SYSTEM_TEAMPROJECTID`, and `$(System.AccessToken)` (all set automatically inside an Azure DevOps pipeline job). +- Does **not** use `permissions.write:` — always uses the build's native token. ### cache-memory (moved to `tools:`) Memory is now configured as a first-class tool under `tools: cache-memory:` instead of `safe-outputs: memory:`. See the [Cache Memory section](./tools.md#cache-memory-cache-memory) in `docs/tools.md` for details. diff --git a/docs/template-markers.md b/docs/template-markers.md index 4746832c..36ec1730 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -543,12 +543,13 @@ If `permissions.write` is not configured, this marker is replaced with an empty ## {{ executor_ado_env }} -Generates the complete `env:` block (including the `env:` key) for the Stage 3 executor step. The block contains zero, one, or two lines depending on which features are configured: +Generates the complete `env:` block (including the `env:` key) for the Stage 3 executor step. The block contains zero, one, two, or three lines depending on which features are configured: -* `SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)` — emitted when `permissions.write` is configured. Provides the write-capable ADO token to the executor. +* `SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)` — emitted when `permissions.write` is configured. Provides the write-capable ARM-minted ADO token to the executor; consumed by the write-requiring safe outputs in `WRITE_REQUIRING_SAFE_OUTPUTS`. +* `ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken)` — emitted when any safe output in `SYSTEM_TOKEN_SAFE_OUTPUTS` (currently `upload-pipeline-artifact`) is configured. Provides the build's native job-plan token (the Project Build Service account) to the executor. Required by REST endpoints — most notably the File Container API — whose ACLs are keyed on the build's own identity at job-initialization time and reject ARM-minted SPN bearers regardless of project-level RBAC. * `ADO_AW_DEBUG_GITHUB_TOKEN: $(ADO_AW_DEBUG_GITHUB_TOKEN)` — emitted when `ado-aw-debug.create-issue` is configured. Provides the GitHub PAT used by the debug-only `create-issue` safe output. See [`docs/ado-aw-debug.md`](ado-aw-debug.md). -If neither feature is configured, this marker is replaced with an empty string so that no `env:` block is emitted at all. Note: `System.AccessToken` is never used directly — all ADO tokens come from explicitly configured service connections, and the GitHub PAT is sourced from a dedicated pipeline variable separate from the read-only `GITHUB_TOKEN` the agent sees in Stage 1. +If none of these features is configured, this marker is replaced with an empty string so that no `env:` block is emitted at all. The GitHub PAT is sourced from a dedicated pipeline variable separate from the read-only `GITHUB_TOKEN` the agent sees in Stage 1. ## {{ compiler_version }} diff --git a/src/compile/common.rs b/src/compile/common.rs index fcee1d71..9da064f6 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1482,6 +1482,23 @@ pub(crate) fn debug_create_issue_enabled(front_matter: &FrontMatter) -> bool { .is_some() } +/// Returns `true` when the agent's front matter configures any safe output +/// in [`crate::safeoutputs::SYSTEM_TOKEN_SAFE_OUTPUTS`] (e.g. +/// `upload-pipeline-artifact`). +/// +/// When true, the Stage 3 executor env block must expose +/// `ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken)` so those handlers can +/// authenticate as the build's own job-plan identity — required by REST +/// endpoints (like the File Container API) whose ACLs are keyed on the +/// build's identity at job-initialization time and reject ARM-minted SPN +/// bearers regardless of project-level RBAC. +pub(crate) fn needs_system_access_token(front_matter: &FrontMatter) -> bool { + use crate::safeoutputs::SYSTEM_TOKEN_SAFE_OUTPUTS; + SYSTEM_TOKEN_SAFE_OUTPUTS + .iter() + .any(|name| front_matter.safe_outputs.contains_key(*name)) +} + /// Validate the `ado-aw-debug:` section. /// /// When `create-issue:` is present: @@ -1765,25 +1782,38 @@ pub fn generate_acquire_ado_token(service_connection: Option<&str>, variable_nam /// Generate the env block entries for the executor step (Stage 3 Execution). /// -/// Composed of two independent lines, each conditional on its caller flag: +/// Composed of three independent lines, each conditional on its caller flag: /// * `SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)` when `write_service_connection` /// is `Some` — write-capable ADO token minted via ARM service connection. +/// Consumed by all write-requiring safe outputs in +/// [`crate::safeoutputs::WRITE_REQUIRING_SAFE_OUTPUTS`]. +/// * `ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken)` when +/// `needs_system_access_token` is `true` — the build's native job-plan +/// identity (Project Build Service account). Consumed by safe outputs in +/// [`crate::safeoutputs::SYSTEM_TOKEN_SAFE_OUTPUTS`] (e.g. +/// `upload-pipeline-artifact`) whose target REST endpoints key their ACL +/// on the build's own identity and reject SPN bearers regardless of +/// project-level RBAC. /// * `ADO_AW_DEBUG_GITHUB_TOKEN: $(ADO_AW_DEBUG_GITHUB_TOKEN)` when /// `debug_create_issue_enabled` is `true` — GitHub PAT used by the /// `ado-aw-debug.create-issue` safe output. Sourced from a dedicated /// pipeline variable so it stays separate from the read-only `GITHUB_TOKEN` /// the agent (Stage 1) sees. /// -/// Returns an empty string when both flags are off (no `env:` block emitted -/// — keeps the executor step minimal in pipelines that need neither token). +/// Returns an empty string when all flags are off (no `env:` block emitted +/// — keeps the executor step minimal in pipelines that need no extra tokens). pub fn generate_executor_ado_env( write_service_connection: Option<&str>, debug_create_issue_enabled: bool, + needs_system_access_token: bool, ) -> String { let mut lines: Vec = Vec::new(); if write_service_connection.is_some() { lines.push("SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)".to_string()); } + if needs_system_access_token { + lines.push("ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken)".to_string()); + } if debug_create_issue_enabled { lines.push("ADO_AW_DEBUG_GITHUB_TOKEN: $(ADO_AW_DEBUG_GITHUB_TOKEN)".to_string()); } @@ -1900,8 +1930,12 @@ pub fn generate_enabled_tools_args(front_matter: &FrontMatter) -> String { } /// Validate that write-requiring safe-outputs have a write service connection configured. +/// +/// Tools in [`SYSTEM_TOKEN_SAFE_OUTPUTS`] (e.g. `upload-pipeline-artifact`) +/// are excluded from this check — they always use the build's native +/// `$(System.AccessToken)` rather than an ARM-minted SPN bearer. pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> { - use crate::safeoutputs::WRITE_REQUIRING_SAFE_OUTPUTS; + use crate::safeoutputs::{SYSTEM_TOKEN_SAFE_OUTPUTS, WRITE_REQUIRING_SAFE_OUTPUTS}; let has_write_sc = front_matter .permissions @@ -1914,6 +1948,7 @@ pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> { let missing: Vec<&str> = WRITE_REQUIRING_SAFE_OUTPUTS .iter() + .filter(|name| !SYSTEM_TOKEN_SAFE_OUTPUTS.contains(*name)) .filter(|name| front_matter.safe_outputs.contains_key(**name)) .copied() .collect(); @@ -3349,6 +3384,7 @@ pub async fn compile_shared( .as_ref() .and_then(|p| p.write.as_deref()), debug_create_issue_enabled(front_matter), + needs_system_access_token(front_matter), ); // 10. Validations @@ -6120,7 +6156,7 @@ safe-outputs: #[test] fn test_generate_executor_ado_env_with_connection() { - let result = generate_executor_ado_env(Some("my-sc"), false); + let result = generate_executor_ado_env(Some("my-sc"), false, false); assert!( result.contains("env:"), "Executor env block should include the 'env:' key" @@ -6138,19 +6174,23 @@ safe-outputs: !result.contains("ADO_AW_DEBUG_GITHUB_TOKEN"), "Without debug flag, GitHub token must not be exposed to executor" ); + assert!( + !result.contains("ADO_SYSTEM_ACCESS_TOKEN"), + "Without system-token flag, ADO_SYSTEM_ACCESS_TOKEN must not be exposed" + ); } #[test] fn test_generate_executor_ado_env_none_empty() { assert!( - generate_executor_ado_env(None, false).is_empty(), - "Both flags off should produce empty string (no env block)" + generate_executor_ado_env(None, false, false).is_empty(), + "All flags off should produce empty string (no env block)" ); } #[test] fn test_generate_executor_ado_env_with_create_issue_only() { - let result = generate_executor_ado_env(None, true); + let result = generate_executor_ado_env(None, true, false); assert!(result.starts_with("env:\n"), "Should emit env: block"); assert!( result.contains("ADO_AW_DEBUG_GITHUB_TOKEN: $(ADO_AW_DEBUG_GITHUB_TOKEN)"), @@ -6160,15 +6200,51 @@ safe-outputs: !result.contains("SYSTEM_ACCESSTOKEN"), "No write SC means no ADO access token" ); + assert!( + !result.contains("ADO_SYSTEM_ACCESS_TOKEN"), + "Without system-token flag, ADO_SYSTEM_ACCESS_TOKEN must not be exposed" + ); } #[test] fn test_generate_executor_ado_env_with_both_tokens() { - let result = generate_executor_ado_env(Some("write-sc"), true); + let result = generate_executor_ado_env(Some("write-sc"), true, false); assert!(result.contains("SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)")); assert!(result.contains("ADO_AW_DEBUG_GITHUB_TOKEN: $(ADO_AW_DEBUG_GITHUB_TOKEN)")); } + #[test] + fn test_generate_executor_ado_env_with_system_token_only() { + // upload-pipeline-artifact configured, no write SC, no debug. + let result = generate_executor_ado_env(None, false, true); + assert!(result.starts_with("env:\n"), "Should emit env: block"); + assert!( + result.contains("ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken)"), + "System-token flag must expose ADO_SYSTEM_ACCESS_TOKEN backed by $(System.AccessToken)" + ); + assert!( + !result.contains("SC_WRITE_TOKEN"), + "Without write SC, no SPN bearer should be exposed" + ); + assert!( + !result.contains("ADO_AW_DEBUG_GITHUB_TOKEN"), + "Without debug flag, GitHub token must not be exposed" + ); + } + + #[test] + fn test_generate_executor_ado_env_system_token_alongside_write_sc() { + // Both an ARM write SC and upload-pipeline-artifact configured: the + // SC_WRITE_TOKEN powers the other write-requiring handlers while + // upload-pipeline-artifact reads $(System.AccessToken) via the + // separate ADO_SYSTEM_ACCESS_TOKEN env var. Critical: the two must + // coexist; ADO_SYSTEM_ACCESS_TOKEN must NOT be silenced just because + // SC_WRITE_TOKEN is also present. + let result = generate_executor_ado_env(Some("write-sc"), false, true); + assert!(result.contains("SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)")); + assert!(result.contains("ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken)")); + } + // ─── Security validation tests ──────────────────────────────────────────── #[test] diff --git a/src/mcp.rs b/src/mcp.rs index e4c6717e..aa730c1a 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1206,17 +1206,16 @@ may apply per the workflow's safe-outputs config." description = "Publish a workspace file as an Azure DevOps pipeline artifact that appears \ in the Artifacts tab of the build summary page — visible to all users viewing the build. Use \ this tool when you want users to be able to find and download the file from the ADO UI. \ -Omit `build_id` to target the current pipeline run. When `build_id` is provided, the artifact \ -is published to that specific build. File size, extension, artifact-name and build-id \ -restrictions may apply per the workflow's safe-outputs config." +The artifact is always published to the current pipeline run. File size, extension and \ +artifact-name restrictions may apply per the workflow's safe-outputs config." )] async fn upload_pipeline_artifact( &self, params: Parameters, ) -> Result { info!( - "Tool called: upload-pipeline-artifact - artifact '{}' file '{}' build {:?}", - params.0.artifact_name, params.0.file_path, params.0.build_id + "Tool called: upload-pipeline-artifact - artifact '{}' file '{}'", + params.0.artifact_name, params.0.file_path ); crate::safeoutputs::Validate::validate(¶ms.0).map_err(anyhow_to_mcp_error)?; @@ -1310,7 +1309,6 @@ restrictions may apply per the workflow's safe-outputs config." })?; let result = UploadPipelineArtifactResult::new( - params.0.build_id, params.0.artifact_name.clone(), params.0.file_path.clone(), staged_filename.clone(), @@ -1320,13 +1318,9 @@ restrictions may apply per the workflow's safe-outputs config." self.write_safe_output_file(&result).await .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?; - let build_desc = match params.0.build_id { - Some(id) => format!("build #{}", id), - None => "the current build".to_string(), - }; Ok(CallToolResult::success(vec![Content::text(format!( - "Pipeline artifact '{}' queued from file '{}' ({} bytes) for {}. The artifact will appear in the Artifacts tab after safe output processing.", - result.artifact_name, result.file_path, file_size, build_desc + "Pipeline artifact '{}' queued from file '{}' ({} bytes) for the current build. The artifact will appear in the Artifacts tab after safe output processing.", + result.artifact_name, result.file_path, file_size ))])) } diff --git a/src/safeoutputs/create_pull_request.rs b/src/safeoutputs/create_pull_request.rs index 7868202c..f292a82e 100644 --- a/src/safeoutputs/create_pull_request.rs +++ b/src/safeoutputs/create_pull_request.rs @@ -2926,6 +2926,7 @@ index 0000000..abcdefg ado_project: Some("TestProject".to_string()), ado_project_id: None, access_token: Some("fake-token".to_string()), + system_access_token: None, github_token: None, source_directory: dir.path().to_path_buf(), working_directory: dir.path().to_path_buf(), diff --git a/src/safeoutputs/mod.rs b/src/safeoutputs/mod.rs index 13bc896a..9dc9a698 100644 --- a/src/safeoutputs/mod.rs +++ b/src/safeoutputs/mod.rs @@ -26,11 +26,15 @@ pub const ALWAYS_ON_TOOLS: &[&str] = tool_names![ ReportIncompleteResult, ]; -/// Safe-output tools that require write access to ADO. +/// Safe-output tools that require write access to ADO via an ARM service +/// connection (mapped to `SYSTEM_ACCESSTOKEN` at execution time). +/// /// Compile-time derived from tool types via `ToolResult::NAME`. /// /// Adding a new write-requiring tool: create the struct with `tool_result!{ write = true, ... }`, -/// then add its type to this list. +/// then add its type to this list — *unless* the tool's target REST endpoint +/// keys its ACL on the build's own job-plan identity (Project Build Service +/// account), in which case add it to [`SYSTEM_TOKEN_SAFE_OUTPUTS`] instead. pub const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = tool_names![ CreateWorkItemResult, CommentOnWorkItemResult, @@ -46,13 +50,30 @@ pub const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = tool_names![ CreateBranchResult, UpdatePrResult, UploadBuildAttachmentResult, - UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, SubmitPrReviewResult, ReplyToPrCommentResult, ResolvePrThreadResult, ]; +/// Safe-output tools that write to ADO but MUST use the build's native +/// `$(System.AccessToken)` (the Project Build Service identity) rather than an +/// ARM-minted SPN bearer. +/// +/// These tools target REST endpoints whose ACLs are keyed on the build's +/// job-plan identity at job-initialization time — most notably the File +/// Container API used by `upload-pipeline-artifact`, which returns +/// `HTTP 404 ContainerWriteAccessDeniedException` for any SPN bearer +/// regardless of project-level RBAC. +/// +/// Tools in this list: +/// * DO have `REQUIRES_WRITE = true` (they write to ADO), +/// * do NOT contribute to the `permissions.write` requirement check, and +/// * always read `$(System.AccessToken)` (via the `ADO_SYSTEM_ACCESS_TOKEN` +/// env var) even when an ARM write service connection is configured for +/// the rest of the safe-outputs job. +pub const SYSTEM_TOKEN_SAFE_OUTPUTS: &[&str] = tool_names![UploadPipelineArtifactResult]; + /// Non-MCP safe-output keys handled by the compiler/executor, not the MCP server. /// These must not appear in `--enabled-tools` or they cause real MCP tools to be /// filtered out (the router has no route for them). @@ -813,6 +834,17 @@ mod tests { } } + #[test] + fn test_system_token_subset_of_all_known() { + for name in SYSTEM_TOKEN_SAFE_OUTPUTS { + assert!( + ALL_KNOWN_SAFE_OUTPUTS.contains(name), + "SYSTEM_TOKEN_SAFE_OUTPUTS entry '{}' is missing from ALL_KNOWN_SAFE_OUTPUTS", + name + ); + } + } + #[test] fn test_always_on_subset_of_all_known() { for name in ALWAYS_ON_TOOLS { @@ -868,12 +900,17 @@ mod tests { } /// Verify ALL_KNOWN_SAFE_OUTPUTS has exactly the right count: - /// write tools + diagnostics + non-MCP keys. + /// write tools + system-token tools + diagnostics + non-MCP keys. #[test] fn test_all_known_completeness() { - // The three sub-lists must be disjoint — a tool in multiple lists would + // The four sub-lists must be disjoint — a tool in multiple lists would // be duplicated in ALL_KNOWN and the count would mismatch. for name in WRITE_REQUIRING_SAFE_OUTPUTS { + assert!( + !SYSTEM_TOKEN_SAFE_OUTPUTS.contains(name), + "Tool '{}' appears in both WRITE_REQUIRING and SYSTEM_TOKEN — lists must be disjoint", + name + ); assert!( !ALWAYS_ON_TOOLS.contains(name), "Tool '{}' appears in both WRITE_REQUIRING and ALWAYS_ON — lists must be disjoint", @@ -885,6 +922,18 @@ mod tests { name ); } + for name in SYSTEM_TOKEN_SAFE_OUTPUTS { + assert!( + !ALWAYS_ON_TOOLS.contains(name), + "Tool '{}' appears in both SYSTEM_TOKEN and ALWAYS_ON — lists must be disjoint", + name + ); + assert!( + !NON_MCP_SAFE_OUTPUT_KEYS.contains(name), + "Tool '{}' appears in both SYSTEM_TOKEN and NON_MCP — lists must be disjoint", + name + ); + } for name in ALWAYS_ON_TOOLS { assert!( !NON_MCP_SAFE_OUTPUT_KEYS.contains(name), @@ -894,12 +943,13 @@ mod tests { } let expected = WRITE_REQUIRING_SAFE_OUTPUTS.len() + + SYSTEM_TOKEN_SAFE_OUTPUTS.len() + ALWAYS_ON_TOOLS.len() + NON_MCP_SAFE_OUTPUT_KEYS.len(); assert_eq!( ALL_KNOWN_SAFE_OUTPUTS.len(), expected, - "ALL_KNOWN_SAFE_OUTPUTS should be the union of write + diagnostic + non-MCP lists" + "ALL_KNOWN_SAFE_OUTPUTS should be the union of write + system-token + diagnostic + non-MCP lists" ); } diff --git a/src/safeoutputs/result.rs b/src/safeoutputs/result.rs index c08c24c2..ea19d88e 100644 --- a/src/safeoutputs/result.rs +++ b/src/safeoutputs/result.rs @@ -47,6 +47,23 @@ pub struct ExecutionContext { pub ado_project_id: Option, /// Personal access token or system access token pub access_token: Option, + /// Build-native ADO access token sourced from + /// `$(System.AccessToken)` via the `ADO_SYSTEM_ACCESS_TOKEN` env var. + /// + /// Holds the build's own job-plan identity (Project Build Service + /// account) — distinct from `access_token`, which may be an ARM-minted + /// SPN bearer when a write service connection is configured. + /// + /// Required by safe outputs in + /// [`crate::safeoutputs::SYSTEM_TOKEN_SAFE_OUTPUTS`] (e.g. + /// `upload-pipeline-artifact`) whose target REST endpoints key their + /// ACL on the build's identity at job-initialization time and reject + /// any other bearer (including SPNs) regardless of project-level RBAC. + /// + /// Set to `None` when the compiler did not emit + /// `ADO_SYSTEM_ACCESS_TOKEN` (i.e. no SYSTEM_TOKEN_SAFE_OUTPUTS handler + /// is configured for this pipeline). + pub system_access_token: Option, /// GitHub PAT used by debug-only safe outputs (e.g. `ado-aw-debug.create-issue`). /// Sourced from the `ADO_AW_DEBUG_GITHUB_TOKEN` pipeline variable. Intentionally /// **separate** from `access_token` (ADO) and from the read-only `GITHUB_TOKEN` @@ -212,6 +229,7 @@ impl ExecutionContext { ado_project: env("SYSTEM_TEAMPROJECT"), ado_project_id: env("SYSTEM_TEAMPROJECTID"), access_token: env("SYSTEM_ACCESSTOKEN").or_else(|| env("AZURE_DEVOPS_EXT_PAT")), + system_access_token: env("ADO_SYSTEM_ACCESS_TOKEN"), github_token: env("ADO_AW_DEBUG_GITHUB_TOKEN"), working_directory: std::env::current_dir().unwrap_or_default(), source_directory, diff --git a/src/safeoutputs/upload_build_attachment.rs b/src/safeoutputs/upload_build_attachment.rs index a79f539b..360f7f79 100644 --- a/src/safeoutputs/upload_build_attachment.rs +++ b/src/safeoutputs/upload_build_attachment.rs @@ -822,6 +822,7 @@ attachment-type: "agent-artifact" ado_project: None, ado_project_id: None, access_token: None, + system_access_token: None, github_token: None, source_directory: working_directory.clone(), working_directory, diff --git a/src/safeoutputs/upload_pipeline_artifact.rs b/src/safeoutputs/upload_pipeline_artifact.rs index ea3c0568..0b794287 100644 --- a/src/safeoutputs/upload_pipeline_artifact.rs +++ b/src/safeoutputs/upload_pipeline_artifact.rs @@ -6,24 +6,36 @@ //! build artifacts published through this tool appear in the **Artifacts tab** //! of the build summary page in Azure DevOps. //! -//! The upload is a two-step REST flow that always reuses the agent's own -//! pre-existing build container (Azure DevOps creates one container per build -//! at job initialization and exposes its ID via `BUILD_CONTAINERID`): +//! The artifact is **always** published to the current pipeline run. ADO's +//! File Container API rejects cross-build publishing — the container is +//! ACL-keyed on the build's job-plan identity, and the associate POST is +//! scoped to the current pipeline's project — so the agent cannot target +//! another build. +//! +//! The upload is a two-step REST flow against the agent's own pre-existing +//! build container (Azure DevOps creates one container per build at job +//! initialization and exposes its ID via `BUILD_CONTAINERID`): //! //! 1. **Upload bytes** — `PUT /_apis/resources/Containers/{BUILD_CONTAINERID}?itemPath={folder}/{file}&scope={projectId}` //! sends the file body into the agent's own build container. -//! 2. **Associate artifact** — `POST /{project}/_apis/build/builds/{effective_build_id}/artifacts` +//! 2. **Associate artifact** — `POST /{project}/_apis/build/builds/{BUILD_BUILDID}/artifacts` //! registers a record with `resource.type = "Container"` and -//! `resource.data = "#/{BUILD_CONTAINERID}/{folder}"`. `effective_build_id` -//! is the current build by default, or an agent-supplied `build_id` for -//! cross-build publishing (the artifact record points at the agent's -//! container; ADO does not require the container to belong to the target -//! build — that is how `DownloadBuildArtifacts@1 buildType=specific` works -//! in the wild). +//! `resource.data = "#/{BUILD_CONTAINERID}/{folder}"` on the current +//! build. +//! +//! Both requests authenticate as the build's **own job-plan identity** +//! (`$(System.AccessToken)`, exposed via `ADO_SYSTEM_ACCESS_TOKEN`). The +//! File Container ACL is keyed on this identity at container-create time +//! and rejects ARM-minted SPN bearers with `HTTP 404 +//! ContainerWriteAccessDeniedException`, regardless of project-level RBAC. +//! For this reason `upload-pipeline-artifact` is registered in +//! [`crate::safeoutputs::SYSTEM_TOKEN_SAFE_OUTPUTS`] (not +//! `WRITE_REQUIRING_SAFE_OUTPUTS`) and never uses the +//! `permissions.write:` service connection. //! //! By default the container `folder` is `{artifact_name}__{6 hex hash}` so //! that two calls with the same `artifact_name` (e.g. publishing -//! `TriageSummary` to many failing builds in one run) never silently overwrite +//! `TriageSummary` twice with different content) never silently overwrite //! each other's bytes. The hash lives only in internal addressing — the //! user-visible `artifact_name` your downstream consumers query is unaffected. //! Set `safe-outputs.upload-pipeline-artifact.require-unique-names: true` to @@ -38,8 +50,7 @@ //! * **Stage 3 (executor, outside the sandbox):** reads the staged file from //! `ctx.working_directory.join(staged_file)`, applies operator-supplied //! limits (`max-file-size`, `allowed-extensions`, `allowed-artifact-names`, -//! `allowed-build-ids`, `name-prefix`), resolves the target build ID, and -//! executes the two-step upload flow. +//! `name-prefix`), and executes the two-step upload flow. use ado_aw_derive::SanitizeConfig; use log::{debug, info, warn}; @@ -57,21 +68,6 @@ use anyhow::{Context, ensure}; /// Parameters for publishing a workspace file as an ADO pipeline artifact. #[derive(Deserialize, JsonSchema)] pub struct UploadPipelineArtifactParams { - /// The build ID to publish the artifact to. **Omit to target the current - /// pipeline run** — the executor resolves the build ID from the - /// `BUILD_BUILDID` environment variable automatically. When provided, - /// must be a positive integer. - /// - /// **Cross-build behavior:** when set to a build other than the current - /// run, the artifact bytes still live in the agent's own build container; - /// only the artifact *record* (`name`, `data` pointer) is associated with - /// the target build. This means cross-published artifacts share the - /// agent build's retention policy — if the agent's build is purged, the - /// cross-referenced artifact on the target build stops being downloadable. - /// Cross-project publishing is not supported (the associate POST uses - /// the current pipeline's project). - pub build_id: Option, - /// The artifact name shown in the Artifacts tab. ADO requires a non-empty /// name made of alphanumerics, `-`, `_`, or `.`. Must be 1-100 characters /// and must not start with `.`. @@ -85,10 +81,6 @@ pub struct UploadPipelineArtifactParams { impl Validate for UploadPipelineArtifactParams { fn validate(&self) -> anyhow::Result<()> { - if let Some(id) = self.build_id { - ensure!(id > 0, "build_id must be positive when specified"); - } - ensure!( self.artifact_name.len() <= 100, "artifact_name must be at most 100 characters" @@ -129,7 +121,6 @@ impl Validate for UploadPipelineArtifactParams { /// Internal params struct for the `tool_result!` macro's `TryFrom` plumbing. #[derive(Deserialize, JsonSchema)] struct UploadPipelineArtifactResultFields { - build_id: Option, artifact_name: String, file_path: String, staged_file: String, @@ -146,9 +137,6 @@ tool_result! { default_max = 3, /// Result of publishing a workspace file as an ADO pipeline artifact. pub struct UploadPipelineArtifactResult { - /// Build ID the artifact should be published to. `None` means "current - /// build" — resolved at execution time from `BUILD_BUILDID`. - build_id: Option, /// Artifact name as proposed by the agent (pre-prefix). artifact_name: String, /// Original file path proposed by the agent. @@ -171,7 +159,6 @@ impl SanitizeContent for UploadPipelineArtifactResult { impl UploadPipelineArtifactResult { /// Construct a result after the agent's file has been staged. pub fn new( - build_id: Option, artifact_name: String, file_path: String, staged_file: String, @@ -180,7 +167,6 @@ impl UploadPipelineArtifactResult { ) -> Self { Self { name: ::NAME.to_string(), - build_id, artifact_name, file_path, staged_file, @@ -212,27 +198,21 @@ pub struct UploadPipelineArtifactConfig { #[serde(default, rename = "allowed-artifact-names")] pub allowed_artifact_names: Vec, - /// Restrict which build IDs the agent may publish to. Empty means any - /// build ID accessible to the executor's token is allowed. This check - /// is skipped when `build_id` is omitted (targeting the current build). - #[serde(default, rename = "allowed-build-ids")] - pub allowed_build_ids: Vec, - /// Prefix prepended to the agent-supplied artifact name before publishing. #[serde(default, rename = "name-prefix")] pub name_prefix: Option, /// When `false` (default), the executor inserts a short hash suffix into - /// the internal container folder so multiple calls in one agent run with - /// the same `artifact_name` (e.g. publishing `TriageSummary` to many - /// failing builds at once) do not silently overwrite each other's bytes - /// in the agent's shared file container. The suffix lives only in - /// internal addressing — the user-visible `artifact_name` your downstream - /// consumers query is unaffected. + /// the internal container folder so two calls with the same + /// `artifact_name` and different content (e.g. publishing + /// `TriageSummary` after a code change) do not silently overwrite each + /// other's bytes in the build's file container. The suffix lives only + /// in internal addressing — the user-visible `artifact_name` your + /// downstream consumers query is unaffected. /// /// Set to `true` to use a clean folder name (`{artifact_name}` exactly) - /// and reject any in-run reuse of `(effective_build_id, artifact_name)` - /// with a clear error before any HTTP call. + /// and reject any in-run reuse of `artifact_name` with a clear error + /// before any HTTP call. #[serde(default, rename = "require-unique-names")] pub require_unique_names: bool, } @@ -263,7 +243,6 @@ impl Default for UploadPipelineArtifactConfig { max_file_size: PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE, allowed_extensions: Vec::new(), allowed_artifact_names: Vec::new(), - allowed_build_ids: Vec::new(), name_prefix: None, require_unique_names: false, } @@ -273,51 +252,28 @@ impl Default for UploadPipelineArtifactConfig { #[async_trait::async_trait] impl Executor for UploadPipelineArtifactResult { fn dry_run_summary(&self) -> String { - match self.build_id { - Some(id) => format!( - "publish '{}' as pipeline artifact '{}' on build #{}", - self.file_path, self.artifact_name, id - ), - None => format!( - "publish '{}' as pipeline artifact '{}' on current build", - self.file_path, self.artifact_name - ), - } + format!( + "publish '{}' as pipeline artifact '{}' on the current build", + self.file_path, self.artifact_name + ) } async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { - let effective_build_id: i64 = match self.build_id { - Some(id) => id, - None => { - let current = ctx.build_id.context( - "build_id was not specified and BUILD_BUILDID is not set — \ - cannot determine which build to publish the artifact to", - )?; - i64::try_from(current).context("BUILD_BUILDID value overflows i64")? - } + let effective_build_id: i64 = { + let current = ctx.build_id.context( + "BUILD_BUILDID is not set — \ + cannot determine which build to publish the artifact to", + )?; + i64::try_from(current).context("BUILD_BUILDID value overflows i64")? }; info!( - "Publishing '{}' as pipeline artifact '{}' on build #{}{}", - self.file_path, - self.artifact_name, - effective_build_id, - if self.build_id.is_none() { " (current build)" } else { "" } + "Publishing '{}' as pipeline artifact '{}' on build #{} (current build)", + self.file_path, self.artifact_name, effective_build_id, ); let config: UploadPipelineArtifactConfig = ctx.get_tool_config("upload-pipeline-artifact"); - // ── Build-ID allow-list ────────────────────────────────────────── - if self.build_id.is_some() - && !config.allowed_build_ids.is_empty() - && !config.allowed_build_ids.contains(&effective_build_id) - { - return Ok(ExecutionResult::failure(format!( - "Build ID {} is not in the allowed-build-ids list", - effective_build_id - ))); - } - // ── Name-prefix ───────────────────────────────────────────────── if let Some(prefix) = &config.name_prefix && prefix.len() > 50 @@ -411,12 +367,11 @@ impl Executor for UploadPipelineArtifactResult { if ctx.dry_run { return Ok(ExecutionResult::success(format!( - "[dry-run] would publish '{}' ({} bytes) as pipeline artifact '{}' on build #{}{}", + "[dry-run] would publish '{}' ({} bytes) as pipeline artifact '{}' on build #{} (current build)", self.file_path, file_size, final_name, effective_build_id, - if self.build_id.is_none() { " (current build)" } else { "" } ))); } @@ -444,15 +399,31 @@ impl Executor for UploadPipelineArtifactResult { .ado_project_id .as_ref() .context("SYSTEM_TEAMPROJECTID not set — required for pipeline artifact upload")?; + // `upload-pipeline-artifact` MUST authenticate as the build's own + // job-plan identity ($(System.AccessToken), exposed via + // ADO_SYSTEM_ACCESS_TOKEN). The File Container ACL is keyed on this + // identity at container-create time and rejects ARM-minted SPN + // bearers (such as the SC_WRITE_TOKEN routed through + // SYSTEM_ACCESSTOKEN for other write-requiring safe outputs) with + // `HTTP 404 ContainerWriteAccessDeniedException`. + // + // Prefer `system_access_token` (always present in correctly-compiled + // pipelines that include upload-pipeline-artifact); fall back to + // `access_token` only so test fixtures and older compiled lock + // files keep working. let token = ctx - .access_token - .as_ref() - .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - - // Resolve the agent's own build container ID (Azure DevOps pre-creates + .system_access_token + .as_deref() + .or(ctx.access_token.as_deref()) + .context( + "No access token available (ADO_SYSTEM_ACCESS_TOKEN, \ + SYSTEM_ACCESSTOKEN, or AZURE_DEVOPS_EXT_PAT)", + )?; + + // Resolve the build's own file container ID. Azure DevOps pre-creates // one container per build at job initialization and exposes it via - // BUILD_CONTAINERID). All artifacts in the build share this container, - // including those whose record we associate with a different build. + // BUILD_CONTAINERID; all artifacts in this build live in this + // container, differentiated by item path. let container_id = ctx.build_container_id.context( "BUILD_CONTAINERID not set or invalid — required to publish a \ pipeline artifact; this tool must run inside an Azure DevOps \ @@ -460,10 +431,10 @@ impl Executor for UploadPipelineArtifactResult { )?; // ── Per-run dedupe (when require-unique-names is set) ──────────── - // Reject reuse of (effective_build_id, final_name) before any HTTP - // call so two cross-build calls sharing a name don't silently - // overwrite each other's bytes in the shared container. - let dedupe_key = format!("{}/{}", effective_build_id, final_name); + // Reject reuse of `final_name` before any HTTP call so two calls + // sharing a name don't silently overwrite each other's bytes in + // the shared container. + let dedupe_key = final_name.clone(); if config.require_unique_names { let seen = ctx .uploaded_pipeline_artifact_keys @@ -472,9 +443,8 @@ impl Executor for UploadPipelineArtifactResult { if seen.contains(&dedupe_key) { return Ok(ExecutionResult::failure(format!( "upload-pipeline-artifact: artifact_name '{}' was already used \ - on build #{} in this run; require-unique-names is configured \ - to reject reuse", - final_name, effective_build_id + in this run; require-unique-names is configured to reject reuse", + final_name ))); } // Note: the key is inserted only after the HTTP calls succeed below, @@ -483,12 +453,12 @@ impl Executor for UploadPipelineArtifactResult { // Internal container folder. The user-visible artifact name is // `final_name`; the folder name carries an optional discriminator so - // multiple calls in one run sharing the same name but uploading - // different content don't overwrite each other's bytes in the shared - // container. The discriminator is derived from the file content hash - // (already computed above) so distinct content always maps to a - // distinct folder — identical content maps to the same folder, which - // is safe (idempotent PUT). The discriminator is invisible in standard + // two calls sharing the same name but uploading different content + // don't overwrite each other's bytes in the shared container. The + // discriminator is derived from the file content hash (already + // computed above) so distinct content always maps to a distinct + // folder — identical content maps to the same folder, which is safe + // (idempotent PUT). The discriminator is invisible in standard // download paths (web UI zip wrapper, DownloadBuildArtifacts@1, // DownloadPipelineArtifact@2 — all strip the prefix) and is only seen // by callers that hit `GET /_apis/resources/Containers/{id}?itemPath=…` @@ -639,9 +609,9 @@ impl Executor for UploadPipelineArtifactResult { .unwrap_or_else(|_| "Unknown error".to_string()); // Best-effort hint when the most common failure modes show up. let hint = match status.as_u16() { - 401 | 403 => " — token may lack 'Build (Read & Execute)' scope on the target build's project", - 404 => " — target build does not exist or is in a different project (cross-project publishing is not supported)", - 409 => " — an artifact with this name already exists on the target build", + 401 | 403 => " — token may lack 'Build (Read & Execute)' scope on this project", + 404 => " — current build was not found in this project (the token may be unable to access build records)", + 409 => " — an artifact with this name already exists on the current build", _ => "", }; Ok(ExecutionResult::failure(format!( @@ -676,12 +646,10 @@ mod tests { } fn make_params( - build_id: Option, artifact_name: &str, file_path: &str, ) -> UploadPipelineArtifactParams { UploadPipelineArtifactParams { - build_id, artifact_name: artifact_name.to_string(), file_path: file_path.to_string(), } @@ -691,43 +659,26 @@ mod tests { #[test] fn test_params_validate_accepts_valid() { - assert!(make_params(Some(1), "agent-report", "out/report.pdf") + assert!(make_params("agent-report", "out/report.pdf") .validate() .is_ok()); - assert!(make_params(None, "agent-report", "out/report.pdf") - .validate() - .is_ok()); - } - - #[test] - fn test_validation_rejects_zero_build_id() { - assert!(make_params(Some(0), "report", "out/report.pdf") - .validate() - .is_err()); - } - - #[test] - fn test_validation_rejects_negative_build_id() { - assert!(make_params(Some(-1), "report", "out/report.pdf") - .validate() - .is_err()); } #[test] fn test_validation_rejects_empty_artifact_name() { - assert!(make_params(None, "", "out/report.pdf").validate().is_err()); + assert!(make_params("", "out/report.pdf").validate().is_err()); } #[test] fn test_validation_rejects_artifact_name_with_spaces() { - assert!(make_params(None, "my report", "out/report.pdf") + assert!(make_params("my report", "out/report.pdf") .validate() .is_err()); } #[test] fn test_validation_rejects_leading_dot_artifact_name() { - assert!(make_params(None, ".hidden", "out/report.pdf") + assert!(make_params(".hidden", "out/report.pdf") .validate() .is_err()); } @@ -735,47 +686,47 @@ mod tests { #[test] fn test_validation_rejects_long_artifact_name() { let long_name = "a".repeat(101); - assert!(make_params(None, &long_name, "out/report.pdf") + assert!(make_params(&long_name, "out/report.pdf") .validate() .is_err()); } #[test] fn test_validation_rejects_empty_file_path() { - assert!(make_params(None, "report", "").validate().is_err()); + assert!(make_params("report", "").validate().is_err()); } #[test] fn test_validation_rejects_traversal_in_file_path() { - assert!(make_params(None, "report", "../etc/passwd") + assert!(make_params("report", "../etc/passwd") .validate() .is_err()); } #[test] fn test_validation_rejects_null_bytes_in_file_path() { - assert!(make_params(None, "report", "out/report\0.pdf") + assert!(make_params("report", "out/report\0.pdf") .validate() .is_err()); } #[test] fn test_validation_rejects_newline_in_file_path() { - assert!(make_params(None, "report", "out\n/report.pdf") + assert!(make_params("report", "out\n/report.pdf") .validate() .is_err()); } #[test] fn test_validation_rejects_carriage_return_in_file_path() { - assert!(make_params(None, "report", "out\r/report.pdf") + assert!(make_params("report", "out\r/report.pdf") .validate() .is_err()); } #[test] fn test_validation_rejects_colon_in_file_path() { - assert!(make_params(None, "report", "C:\\out\\report.pdf") + assert!(make_params("report", "C:\\out\\report.pdf") .validate() .is_err()); } @@ -784,7 +735,6 @@ mod tests { fn test_validation_rejects_pipeline_command_sequences_in_file_path() { assert!( make_params( - None, "report", "##vso[task.setvariable variable=EXPLOIT]value.txt" ) @@ -792,7 +742,7 @@ mod tests { .is_err() ); assert!( - make_params(None, "report", "##[error]value.txt") + make_params("report", "##[error]value.txt") .validate() .is_err() ); @@ -801,7 +751,6 @@ mod tests { #[test] fn test_dry_run_summary() { let result = UploadPipelineArtifactResult::new( - None, "agent-report".to_string(), "out/report.pdf".to_string(), "staged-abc123.pdf".to_string(), @@ -810,16 +759,6 @@ mod tests { ); assert!(result.dry_run_summary().contains("agent-report")); assert!(result.dry_run_summary().contains("current build")); - - let result_with_id = UploadPipelineArtifactResult::new( - Some(42), - "agent-report".to_string(), - "out/report.pdf".to_string(), - "staged-abc123.pdf".to_string(), - 1024, - DUMMY_HASH.to_string(), - ); - assert!(result_with_id.dry_run_summary().contains("build #42")); } #[test] @@ -828,7 +767,6 @@ mod tests { assert_eq!(config.max_file_size, PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE); assert!(config.allowed_extensions.is_empty()); assert!(config.allowed_artifact_names.is_empty()); - assert!(config.allowed_build_ids.is_empty()); assert!(config.name_prefix.is_none()); } @@ -850,6 +788,19 @@ name-prefix: "ci-" assert_eq!(config.name_prefix.as_deref(), Some("ci-")); } + /// Unknown YAML keys (e.g. legacy `allowed-build-ids` or the removed + /// `build_id` field) should be silently ignored by serde — keeping + /// older lock files parseable without breaking compilation. + #[test] + fn test_config_ignores_removed_keys() { + let yaml = r#" +max-file-size: 1024 +allowed-build-ids: [1, 2, 3] +"#; + let config: UploadPipelineArtifactConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.max_file_size, 1024); + } + #[tokio::test] async fn test_dry_run_succeeds() { let dir = tempfile::tempdir().unwrap(); @@ -859,7 +810,6 @@ name-prefix: "ci-" let hash = crate::hash::sha256_hex(content); let result = UploadPipelineArtifactResult::new( - None, "agent-report".to_string(), "out/report.pdf".to_string(), "staged-file.pdf".to_string(), @@ -887,7 +837,6 @@ name-prefix: "ci-" std::fs::write(&staged, b"test content").unwrap(); let result = UploadPipelineArtifactResult::new( - None, "report".to_string(), "out/report.pdf".to_string(), "staged-file.pdf".to_string(), @@ -915,7 +864,6 @@ name-prefix: "ci-" std::fs::write(&staged, content).unwrap(); let result = UploadPipelineArtifactResult::new( - None, "report".to_string(), "out/report.pdf".to_string(), "staged-file.pdf".to_string(), @@ -940,36 +888,6 @@ name-prefix: "ci-" assert!(exec_result.message.contains("exceeds maximum")); } - #[tokio::test] - async fn test_rejects_disallowed_build_id() { - let dir = tempfile::tempdir().unwrap(); - let staged = dir.path().join("staged-file.pdf"); - std::fs::write(&staged, b"test").unwrap(); - - let result = UploadPipelineArtifactResult::new( - Some(999), - "report".to_string(), - "out/report.pdf".to_string(), - "staged-file.pdf".to_string(), - 4, - DUMMY_HASH.to_string(), - ); - - let mut ctx = ExecutionContext { - working_directory: dir.path().to_path_buf(), - dry_run: false, - ..Default::default() - }; - ctx.tool_configs.insert( - "upload-pipeline-artifact".to_string(), - serde_json::json!({"allowed-build-ids": [123, 456]}), - ); - - let exec_result = result.execute_impl(&ctx).await.unwrap(); - assert!(!exec_result.success); - assert!(exec_result.message.contains("not in the allowed-build-ids")); - } - #[tokio::test] async fn test_rejects_disallowed_extension() { let dir = tempfile::tempdir().unwrap(); @@ -978,7 +896,6 @@ name-prefix: "ci-" std::fs::write(&staged, content).unwrap(); let result = UploadPipelineArtifactResult::new( - None, "report".to_string(), "out/report.exe".to_string(), "staged-file.exe".to_string(), @@ -1013,7 +930,6 @@ name-prefix: "ci-" let hash = crate::hash::sha256_hex(content); let result = UploadPipelineArtifactResult::new( - None, "agent-report".to_string(), "out/report.pdf".to_string(), "staged-file.pdf".to_string(), @@ -1043,6 +959,136 @@ name-prefix: "ci-" ); } + /// `system_access_token` (sourced from `ADO_SYSTEM_ACCESS_TOKEN` — + /// `$(System.AccessToken)`) takes precedence over `access_token` + /// (which may carry an ARM-minted SPN bearer for the other write- + /// requiring safe outputs). The File Container ACL rejects SPN + /// bearers, so this preference must hold even when both are set. + #[tokio::test] + async fn test_prefers_system_access_token_over_access_token() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged-file.pdf"); + let content = b"test content"; + std::fs::write(&staged, content).unwrap(); + let hash = crate::hash::sha256_hex(content); + + let result = UploadPipelineArtifactResult::new( + "agent-report".to_string(), + "out/report.pdf".to_string(), + "staged-file.pdf".to_string(), + content.len() as u64, + hash, + ); + + // No build_container_id forces an early error so we can verify + // the token selection logic ran without firing an HTTP request. + let ctx = ExecutionContext { + working_directory: dir.path().to_path_buf(), + build_id: Some(123), + dry_run: false, + ado_org_url: Some("https://dev.azure.com/test".to_string()), + ado_project: Some("TestProject".to_string()), + ado_project_id: Some("proj-guid".to_string()), + system_access_token: Some("native-token".to_string()), + access_token: Some("spn-bearer".to_string()), + ..Default::default() + }; + + // Reaches the BUILD_CONTAINERID check, meaning a token was + // successfully selected. Asserting on the error path is enough + // — the runtime branch would otherwise need a live HTTP server. + let err = result.execute_impl(&ctx).await.unwrap_err(); + assert!( + err.to_string().contains("BUILD_CONTAINERID"), + "expected BUILD_CONTAINERID error after token selection, got: {}", + err + ); + } + + /// When `system_access_token` is `None`, the executor must fall back to + /// `access_token` so older lock files / test fixtures keep working + /// while still producing a clear missing-token error if neither is set. + #[tokio::test] + async fn test_falls_back_to_access_token_when_system_token_absent() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged-file.pdf"); + let content = b"test content"; + std::fs::write(&staged, content).unwrap(); + let hash = crate::hash::sha256_hex(content); + + let result = UploadPipelineArtifactResult::new( + "agent-report".to_string(), + "out/report.pdf".to_string(), + "staged-file.pdf".to_string(), + content.len() as u64, + hash, + ); + + let ctx = ExecutionContext { + working_directory: dir.path().to_path_buf(), + build_id: Some(123), + dry_run: false, + ado_org_url: Some("https://dev.azure.com/test".to_string()), + ado_project: Some("TestProject".to_string()), + ado_project_id: Some("proj-guid".to_string()), + // system_access_token is None — fall back to access_token. + access_token: Some("fallback-token".to_string()), + ..Default::default() + }; + + let err = result.execute_impl(&ctx).await.unwrap_err(); + assert!( + err.to_string().contains("BUILD_CONTAINERID"), + "expected BUILD_CONTAINERID error after fallback, got: {}", + err + ); + } + + /// When neither `system_access_token` nor `access_token` is set, the + /// executor must produce a clear missing-token error rather than + /// silently sending unauthenticated requests. + #[tokio::test] + async fn test_fails_when_no_token_available() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged-file.pdf"); + let content = b"test content"; + std::fs::write(&staged, content).unwrap(); + let hash = crate::hash::sha256_hex(content); + + let result = UploadPipelineArtifactResult::new( + "agent-report".to_string(), + "out/report.pdf".to_string(), + "staged-file.pdf".to_string(), + content.len() as u64, + hash, + ); + + let ctx = ExecutionContext { + working_directory: dir.path().to_path_buf(), + build_id: Some(123), + dry_run: false, + ado_org_url: Some("https://dev.azure.com/test".to_string()), + ado_project: Some("TestProject".to_string()), + ado_project_id: Some("proj-guid".to_string()), + // No system_access_token, no access_token. + system_access_token: None, + access_token: None, + ..Default::default() + }; + + let err = result.execute_impl(&ctx).await.unwrap_err(); + assert!( + err.to_string().contains("No access token available"), + "expected missing-token error, got: {}", + err + ); + assert!( + err.to_string().contains("ADO_SYSTEM_ACCESS_TOKEN"), + "error should mention ADO_SYSTEM_ACCESS_TOKEN, got: {}", + err + ); + } + /// The default container folder embeds a 6-hex hash discriminator derived /// from the file content hash; two calls uploading different content /// (regardless of staged file name) produce different folders so their @@ -1075,8 +1121,8 @@ name-prefix: "ci-" /// When `require-unique-names` is set, the executor uses the clean folder /// name (no discriminator suffix) — and the per-run dedupe set on the - /// ExecutionContext rejects a second call with the same - /// (effective_build_id, final_name) before any HTTP call is made. + /// ExecutionContext rejects a second call with the same `final_name` + /// before any HTTP call is made. #[tokio::test] async fn test_require_unique_names_rejects_in_run_reuse() { let dir = tempfile::tempdir().unwrap(); @@ -1106,11 +1152,10 @@ name-prefix: "ci-" // a live HTTP server to run the upload. { let mut seen = ctx.uploaded_pipeline_artifact_keys.lock().unwrap(); - seen.insert("100/TriageSummary".to_string()); + seen.insert("TriageSummary".to_string()); } let second = UploadPipelineArtifactResult::new( - Some(100), "TriageSummary".to_string(), "out/triage.md".to_string(), "staged-b.md".to_string(), diff --git a/tests/safe-outputs/upload-pipeline-artifact.lock.yml b/tests/safe-outputs/upload-pipeline-artifact.lock.yml index 3a20bd53..103ab6fd 100644 --- a/tests/safe-outputs/upload-pipeline-artifact.lock.yml +++ b/tests/safe-outputs/upload-pipeline-artifact.lock.yml @@ -1,5 +1,5 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/safe-outputs/upload-pipeline-artifact.md" version=0.30.1 +# @ado-aw source="tests/safe-outputs/upload-pipeline-artifact.md" version=0.31.1 name: "Daily safe-output smoke upload-pipeline-artifact-$(BuildID)" @@ -61,7 +61,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.47" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.48" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -99,7 +99,7 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: "Install Copilot CLI (v1.0.47)" + displayName: "Install Copilot CLI (v1.0.48)" - bash: | copilot --version @@ -108,7 +108,7 @@ jobs: - bash: | set -eo pipefail - COMPILER_VERSION="0.30.1" + COMPILER_VERSION="0.31.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -123,7 +123,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: "Download agentic pipeline compiler (v0.30.1)" + displayName: "Download agentic pipeline compiler (v0.31.1)" - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -200,17 +200,7 @@ jobs: - bash: | # Write agent instructions to /tmp so it's accessible inside AWF container cat > "/tmp/awf-tools/agent-prompt.md" << 'AGENT_PROMPT_EOF' - ## Daily smoke for upload-pipeline-artifact - - You are a smoke test. The setup job has written - `$(Build.ArtifactStagingDirectory)/ado-aw-smoke.txt`. Call exactly one - safe-output tool: `upload-pipeline-artifact`. Use these literal values - (no improvisation): - - - artifact_name: "ado-aw-smoke-$(Build.BuildId)-upload-pipeline-artifact" - - file_path: "$(Build.ArtifactStagingDirectory)/ado-aw-smoke.txt" - - Do not call any other tool. After the safe output is emitted, stop. + {{#runtime-import tests/safe-outputs/upload-pipeline-artifact.md}} AGENT_PROMPT_EOF echo "Agent prompt:" @@ -225,7 +215,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.25.44" + AWF_VERSION="0.25.48" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -242,17 +232,56 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: "Download AWF (Agentic Workflow Firewall) v0.25.44" + displayName: "Download AWF (Agentic Workflow Firewall) v0.25.48" + + - bash: | + set -eo pipefail + + docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.48 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.48 + docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.48 ghcr.io/github/gh-aw-firewall/squid:latest + docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.48 ghcr.io/github/gh-aw-firewall/agent:latest + docker pull ghcr.io/github/gh-aw-mcpg:v0.3.12 + displayName: "Pre-pull AWF and MCPG container images (v0.25.48)" + + - task: NodeTool@0 + inputs: + versionSpec: "20.x" + displayName: "Install Node.js 20.x" + timeoutInMinutes: 5 + condition: succeeded() - bash: | set -eo pipefail + mkdir -p /tmp/ado-aw-scripts + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.31.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.31.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - + unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ + displayName: "Download ado-aw scripts (v0.31.1)" + timeoutInMinutes: 5 + condition: succeeded() - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.44 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.44 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.44 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.44 ghcr.io/github/gh-aw-firewall/agent:latest - docker pull ghcr.io/github/gh-aw-mcpg:v0.3.7 - displayName: "Pre-pull AWF and MCPG container images (v0.25.44)" + - bash: | + set -eo pipefail + node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" + displayName: "Resolve runtime imports (agent prompt)" + condition: succeeded() + + - bash: | + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/upload-pipeline-artifact.md","target":"standalone","version":"0.31.1"} + echo 'ado-aw metadata: source=tests/safe-outputs/upload-pipeline-artifact.md org= repo= version=0.31.1 target=standalone' + displayName: "ado-aw" + + - bash: | + set -eo pipefail + + mkdir -p "$(Agent.TempDirectory)/staging" + cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' + {"agent_name":"Daily safe-output smoke: upload-pipeline-artifact","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.31.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/upload-pipeline-artifact.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} + AW_INFO_EOF + displayName: "Emit aw_info.json" + condition: always() - bash: | cat >> "/tmp/awf-tools/agent-prompt.md" << 'SAFEOUTPUTS_EOF' @@ -351,7 +380,7 @@ jobs: -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ \ \ - ghcr.io/github/gh-aw-mcpg:v0.3.7 \ + ghcr.io/github/gh-aw-mcpg:v0.3.12 \ --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! @@ -523,7 +552,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.47" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.48" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -561,7 +590,7 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: "Install Copilot CLI (v1.0.47)" + displayName: "Install Copilot CLI (v1.0.48)" - bash: | copilot --version @@ -570,7 +599,7 @@ jobs: - bash: | set -eo pipefail - COMPILER_VERSION="0.30.1" + COMPILER_VERSION="0.31.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -585,7 +614,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: "Download agentic pipeline compiler (v0.30.1)" + displayName: "Download agentic pipeline compiler (v0.31.1)" - task: DockerInstaller@0 displayName: "Install Docker" @@ -595,7 +624,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.25.44" + AWF_VERSION="0.25.48" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -612,16 +641,16 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: "Download AWF (Agentic Workflow Firewall) v0.25.44" + displayName: "Download AWF (Agentic Workflow Firewall) v0.25.48" - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.44 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.44 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.44 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.44 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: "Pre-pull AWF container images (v0.25.44)" + docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.48 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.48 + docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.48 ghcr.io/github/gh-aw-firewall/squid:latest + docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.48 ghcr.io/github/gh-aw-firewall/agent:latest + displayName: "Pre-pull AWF container images (v0.25.48)" - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" @@ -818,7 +847,7 @@ jobs: - bash: | set -eo pipefail - COMPILER_VERSION="0.30.1" + COMPILER_VERSION="0.31.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -833,7 +862,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: "Download agentic pipeline compiler (v0.30.1)" + displayName: "Download agentic pipeline compiler (v0.31.1)" - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" @@ -857,6 +886,7 @@ jobs: workingDirectory: $(Build.SourcesDirectory) env: SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN) + ADO_SYSTEM_ACCESS_TOKEN: $(System.AccessToken) - bash: | # Copy all logs to output directory for artifact upload