From bebae46c92d6a9fc9ebe459b091d3db72719408f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 12:53:26 +0000 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20fix=20documentation=20drift=20?= =?UTF-8?q?=E2=80=94=20enable=20command,=20engine=20install=20steps,=20com?= =?UTF-8?q?mand=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: add missing `enable` command to CLI Reference section - docs/template-markers.md: update `{{ engine_install_steps }}` to describe target-aware install strategy (NuGet for 1ES, GitHub Releases for others) - docs/engine.md: clarify `command` field description — skips the default engine binary installation which is NuGet for 1ES targets but GitHub Releases for standalone/job/stage targets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + docs/engine.md | 2 +- docs/template-markers.md | 12 ++++++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8ce14bc0..c2ef6b5e 100644 --- a/README.md +++ b/README.md @@ -494,6 +494,7 @@ Commands: mcp-http Run as an HTTP MCP server (for MCPG integration) execute Execute safe outputs (Stage 3) configure Detect agentic pipelines and update GITHUB_TOKEN on ADO definitions + enable Register ADO build definitions for compiled pipelines and ensure they are enabled Options: -v, --verbose Enable info-level logging diff --git a/docs/engine.md b/docs/engine.md index 50b57f72..73964d64 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -29,7 +29,7 @@ engine: | `api-target` | string | *(none)* | Custom API endpoint hostname for GHES/GHEC (e.g., `"api.acme.ghe.com"`). Adds `--api-target ` to the CLI invocation and adds the hostname to the AWF network allowlist. | | `args` | list | `[]` | Custom CLI arguments appended after compiler-generated args. Subject to shell-safety validation and blocked from overriding compiler-controlled flags (`--prompt`, `--allow-tool`, `--disable-builtin-mcps`, etc.). | | `env` | map | *(none)* | Engine-specific environment variables merged into the sandbox step's `env:` block. Keys must be valid env var names; values must not contain ADO expressions (`$(`, `${{`) or pipeline command injection (`##vso[`). Compiler-controlled keys (`GITHUB_TOKEN`, `PATH`, `BASH_ENV`, etc.) are blocked. | -| `command` | string | *(none)* | Custom engine executable path (skips default NuGet installation). The path must be accessible inside the AWF container (e.g., `/tmp/...` or workspace-mounted paths). | +| `command` | string | *(none)* | Custom engine executable path (skips the default engine binary installation — NuGet for `target: 1es`, GitHub Releases for all other targets). The path must be accessible inside the AWF container (e.g., `/tmp/...` or workspace-mounted paths). | ### `timeout-minutes` diff --git a/docs/template-markers.md b/docs/template-markers.md index 38a8344c..00ee91e7 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -161,12 +161,20 @@ job- and stage-level templates don't emit a top-level pipeline name. ## {{ 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()`. The install strategy is **target-aware**: + +**For `target: 1es`** — authenticates with an internal Azure Artifacts NuGet feed and installs the package: - `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/msazuresphere/_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 (standalone, job, stage)** — downloads from GitHub Releases with SHA256 checksum verification: +- Bash step that: resolves `SHA256SUMS.txt` and the tarball from the GitHub Releases URL for the configured version, verifies the SHA256 checksum, extracts the binary, copies it to `/tmp/awf-tools/copilot` +- Bash step to verify installation + +Both paths stage the binary at `/tmp/awf-tools/copilot`. + Returns empty when `engine.command` is set (user provides own binary). ## {{ engine_run }} From 727cbfb3476fcea348507cde7cc128833a8337bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 13:27:03 +0000 Subject: [PATCH 2/4] fix(compile): use user's ADO org in 1ES NuGet feed URL instead of hardcoded msazuresphere Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/e1c74427-9ecf-4ff6-b925-4822facdd4de Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/template-markers.md | 5 +- .../docs/reference/template-markers.mdx | 11 +- src/compile/common.rs | 2 +- src/engine.rs | 113 ++++++++++++++++-- 4 files changed, 114 insertions(+), 17 deletions(-) diff --git a/docs/template-markers.md b/docs/template-markers.md index 00ee91e7..97cf5ba9 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..d880c4df 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,59 @@ 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 to prevent injection. + if !is_valid_hostname(org) { + 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 +1055,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 +1065,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 +1074,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 +1084,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 +1094,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 +1104,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 +1116,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"); From b5d375ce383e25b06cf531809ea3a7fef9bcfd63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 13:32:43 +0000 Subject: [PATCH 3/4] fix(compile): tighten ADO org name validation to alphanumeric and hyphen only Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/e1c74427-9ecf-4ff6-b925-4822facdd4de Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/engine.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index d880c4df..ddf606df 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -550,11 +550,15 @@ fn copilot_install_steps(engine_config: &EngineConfig, target: &CompileTarget, a // 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 to prevent injection. - if !is_valid_hostname(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.", + Only ASCII alphanumerics and '-' are allowed.", org ); } From 7ad8afbd54a34b621a2c68c2d55db1bec044fb76 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 17 May 2026 18:14:22 +0100 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20fix=20documentation=20drift=20?= =?UTF-8?q?=E2=80=94=20engine=5Flog=5Fdir=20path=20and=20ado/mod.rs=20life?= =?UTF-8?q?cycle=20commands=20(#601)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/template-markers.md: correct {{ engine_log_dir }} from ~/.copilot/logs to $HOME/.copilot/logs, matching src/engine.rs log_dir(). Add explanation that tilde does not expand inside double-quoted bash strings, which would cause the directory check to always fail and silently prevent log collection. - AGENTS.md: remove references to unimplemented lifecycle CLI commands (disable, remove, list, run, status, secrets) from the ado/mod.rs description; only the 'enable' command is currently implemented. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- AGENTS.md | 2 +- docs/template-markers.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 49561a50..0f888099 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ Every compiled pipeline runs as three sequential jobs: │ ├── configure.rs # `configure` CLI command — orchestration shim atop `src/ado/` │ ├── enable.rs # `enable` CLI command — registers ADO build definitions for compiled pipelines and ensures they are enabled │ ├── ado/ # Shared Azure DevOps REST helpers (auth, list/match/PATCH/POST) -│ │ └── mod.rs # Used by `configure` and the lifecycle commands (enable, disable, remove, list, run, status, secrets) +│ │ └── mod.rs # Used by `configure` and the `enable` command (ADO REST helpers: auth, list/match/PATCH/POST) │ ├── detect.rs # Agentic pipeline detection (helper for `configure`) │ ├── ndjson.rs # NDJSON parsing utilities │ ├── sanitize.rs # Input sanitization for safe outputs diff --git a/docs/template-markers.md b/docs/template-markers.md index 97cf5ba9..2ac44d55 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -217,7 +217,9 @@ ADO access tokens (`AZURE_DEVOPS_EXT_PAT`, `SYSTEM_ACCESSTOKEN`) are not part of ## {{ engine_log_dir }} -Should be replaced with the engine's log directory path, generated by `Engine::log_dir()`. For Copilot: `~/.copilot/logs`. Used by log collection steps to copy engine logs to pipeline artifacts. +Should be replaced with the engine's log directory path, generated by `Engine::log_dir()`. For Copilot: `$HOME/.copilot/logs`. Used by log collection steps to copy engine logs to pipeline artifacts. + +> **Note:** `$HOME` is used instead of `~` because tilde does not expand inside double-quoted strings in bash. Using `~` would cause the directory check (`[ -d "~/.copilot/logs" ]`) to always fail, silently preventing log collection. ## {{ pool }}