From 18958b704b739e1de735d7565fec0b5a2c095c42 Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 18 May 2026 15:18:01 +0100 Subject: [PATCH] feat(compile): runtime prompt rendering via composable bash + awk Default behaviour: the agent body is no longer embedded in the compiled pipeline YAML. The compiled Agent job emits a single "Render agent prompt" bash step that, at pipeline runtime, cats the source .md from the workspace, appends extension supplements as labelled heredocs (visible directly in the lock yaml), strips the front matter via awk, and runs a single-pass awk substitution program. Body-only edits to the source .md no longer require recompiling the pipeline. Set inlined-imports: true in front matter to opt out and keep the legacy heredoc-embedded behaviour. The single-pass awk substitution recognises four token shapes (backslash-escape $(VAR), parameters expressions, $(VAR), and $[...]) in priority order; replacement values are looked up in awk ENVIRON and inserted verbatim, never re-scanned. This blocks the queue-with-malicious-parameter-value chaining attack without requiring a Node bundle. Supersedes #617. Restructures the v2 design after observing that gh-aw renders prompts via composable inline shell steps rather than a Node bundle; that approach is far more transparent in the generated YAML and avoids a Node install on the Agent VM. Also folds in: - Flattened ado-script.zip layout: top-level gate.js, /tmp/ado-aw-scripts/gate.js runtime path. - Shared needs_scripts_bundle() trait method on CompilerExtension to dedupe the NodeTool@0 + scripts-zip download once per consuming job. - inlined-imports field on FrontMatter. - strip_prefix(" ") fix in the inlined branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 14 +- AGENTS.md | 11 +- docs/ado-script.md | 30 +- docs/extending.md | 20 + docs/filter-ir.md | 13 +- docs/front-matter.md | 33 + docs/template-markers.md | 64 +- scripts/ado-script/src/gate/predicates.ts | 2 +- scripts/ado-script/src/shared/types.gen.ts | 2 +- src/compile/common.rs | 685 +++++++++++++++++- src/compile/extensions/mod.rs | 103 +++ src/compile/extensions/trigger_filters.rs | 127 ++-- src/compile/filter_ir.rs | 18 +- src/compile/types.rs | 82 +++ src/data/1es-base.yml | 10 +- src/data/base.yml | 10 +- src/data/job-base.yml | 10 +- src/data/stage-base.yml | 10 +- tests/compiler_tests.rs | 111 ++- .../fixtures/runtime-prompt-default-agent.md | 11 + .../fixtures/runtime-prompt-inlined-agent.md | 10 + tests/gate_e2e.rs | 2 +- 22 files changed, 1213 insertions(+), 165 deletions(-) create mode 100644 tests/fixtures/runtime-prompt-default-agent.md create mode 100644 tests/fixtures/runtime-prompt-inlined-agent.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34f08aa6..4cfe9f72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,8 +72,18 @@ jobs: - name: Package ado-script bundle run: | set -euo pipefail - cd scripts - zip -r ../ado-script.zip ado-script/dist + # Flatten the ncc per-bundle layout (`dist//index.js`) into + # top-level `.js` files in the zip. Pipelines extract the + # zip to `/tmp/ado-aw-scripts/` and reference each helper by name + # (e.g. `/tmp/ado-aw-scripts/gate.js`), so internal `dist//` + # nesting does not need to leak into the runtime path. + staging="$(mktemp -d)" + for bundle in scripts/ado-script/dist/*/index.js; do + name="$(basename "$(dirname "$bundle")")" + cp "$bundle" "$staging/$name.js" + done + (cd "$staging" && zip "$GITHUB_WORKSPACE/ado-script.zip" *.js) + unzip -l ado-script.zip - name: Upload release assets env: diff --git a/AGENTS.md b/AGENTS.md index 44cc8c5d..a2e7cad6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,7 +157,7 @@ Every compiled pipeline runs as three sequential jobs: │ └── debug-ado-agentic-workflow.md # Guide for troubleshooting a failing agentic pipeline ├── scripts/ # Supporting scripts shipped as release artifacts │ ├── ado-script/ # TypeScript workspace for bundled gate.js (and future bundles) -│ └── gate.js # Bundled gate evaluator (built from scripts/ado-script/, see docs/ado-script.md) +│ └── gate.js # Bundled gate evaluator (Setup-job step; see docs/ado-script.md) ├── tests/ # Integration tests and fixtures ├── docs/ # Per-concept reference documentation (see index below) ├── Cargo.toml # Rust dependencies @@ -237,8 +237,13 @@ index to jump to the right page. rewrite on breaking-change updates, contributor workflow for adding codemods. - [`docs/ado-script.md`](docs/ado-script.md) — `ado-script` workspace - (`scripts/ado-script/`): the bundled TypeScript runtime helpers (today: - `gate.js`), schemars-driven type codegen, and the A2 design decision. + (`scripts/ado-script/`): the bundled TypeScript runtime helpers + (`gate.js` for trigger gates), schemars-driven type codegen, and the + A2 design decision. Note: agent-prompt rendering is intentionally + *not* an `ado-script` bundle — it is an inline bash + awk step, see + [`docs/template-markers.md`](docs/template-markers.md) and + [`docs/front-matter.md`](docs/front-matter.md) for the + `inlined-imports` knob. - [`docs/local-development.md`](docs/local-development.md) — local development setup notes. diff --git a/docs/ado-script.md b/docs/ado-script.md index d363a06c..d32f9ebf 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -12,6 +12,14 @@ pipeline** as runtime helpers. The first (and currently only) bundle is > and how to wire it. See [`docs/tools.md`](tools.md) for what *is* > user-facing. +The runtime **agent-prompt rendering** path (the default for new +agents, opt-out via `inlined-imports: true`) is *not* an `ado-script` +bundle — it is an inline `bash` + `awk` step in the compiled YAML, kept +deliberately small and transparent. See +[`docs/template-markers.md`](template-markers.md) for the +`{{ prepare_agent_prompt }}` marker and +[`docs/front-matter.md`](front-matter.md) for `inlined-imports`. + ## What `gate.js` does `gate.js` is a single-shot Node program that runs as a step in the @@ -158,10 +166,12 @@ scripts/ado-script/ ``` The release workflow (`.github/workflows/release.yml`) runs -`npm ci && npm run build`, then zips `scripts/ado-script/dist/` into -the `ado-script.zip` release asset. Pipelines download that asset at -runtime by URL pinned to the compiler's `CARGO_PKG_VERSION`, verify -its SHA-256 against the `checksums.txt` asset, then extract. +`npm ci && npm run build`, then **flattens** each `dist//index.js` +into a top-level `.js` inside `ado-script.zip` (today just +`gate.js`). Pipelines download that asset at runtime by URL pinned to +the compiler's `CARGO_PKG_VERSION`, verify its SHA-256 against the +`checksums.txt` asset, then extract directly into `/tmp/ado-aw-scripts/`, +where each bundle is referenced by `/tmp/ado-aw-scripts/.js`. ## Schema codegen @@ -208,7 +218,7 @@ steps when any `filters:` block is active: `CARGO_PKG_VERSION`, verifies the zip's SHA-256, then `unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/`. Also capped at `timeoutInMinutes: 5`. -3. **`bash: node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'`** — +3. **`bash: node '/tmp/ado-aw-scripts/gate.js'`** — runs the gate with `GATE_SPEC` and the env-var contract above. The IR-to-bash codegen that produces these steps is @@ -255,10 +265,12 @@ The IR-to-bash codegen that produces these steps is 3. Add vitest tests under `src/poll/__tests__/`. 4. Wire from a new `CompilerExtension` (or extend an existing one) that downloads `ado-script.zip` (already a release asset) and - invokes `node /tmp/ado-aw-scripts/ado-script/dist/poll/index.js` + invokes `node /tmp/ado-aw-scripts/poll.js` as a runtime step. -5. No release-workflow change is needed — `zip -r ado-script/dist` - picks up the new bundle automatically. +5. Extend the release workflow's package step in + `.github/workflows/release.yml` — the flatten loop iterates over + every `dist/*/index.js`, so a new bundle is picked up automatically + as long as its build step writes to `dist//index.js`. ### Local development loop @@ -269,7 +281,7 @@ npm ci # one-time npm run codegen # regenerate types.gen.ts (compiles ado-aw first) npm test # vitest unit tests npm run typecheck # strict tsc --noEmit -npm run build # ncc-bundle to dist/gate/index.js +npm run build # ncc-bundle each src//index.ts to dist//index.js npm run test:smoke # build + smoke test the bundle end-to-end ``` diff --git a/docs/extending.md b/docs/extending.md index 31da4951..73140740 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -49,6 +49,7 @@ pub trait CompilerExtension { fn required_awf_mounts(&self) -> Vec; // AWF Docker volume mounts fn awf_path_prepends(&self) -> Vec; // Directories to add to chroot PATH fn agent_env_vars(&self) -> Vec<(String, String)>; // Agent env vars (e.g., PIP_INDEX_URL) + fn needs_scripts_bundle(&self) -> bool; // Declare dependency on ado-script.zip } ``` @@ -62,6 +63,25 @@ gates or checks that must complete before the agent is launched. This guarantees runtime install steps run before tool steps that may depend on them. +**`prompt_supplement()` delivery**: By default +(`inlined-imports: false`), supplement strings are embedded as +labelled `cat << '__ADO_AW_SUPP__EOF__' ... EOF` heredocs +inside the single `Render agent prompt` bash step emitted by +[`generate_prepare_agent_prompt`](../src/compile/common.rs). The +supplement text is therefore directly visible in the lock yaml and +flows through the same single-pass `awk` substitution as the body, +so an extension can parameterize its supplement using +`${{ parameters.* }}` / `$(VAR)` the same way an author parameterizes +the body. Only when `inlined-imports: true` does the compiler wrap +each supplement in a separate `cat >>` step via `wrap_prompt_append`. + +**`needs_scripts_bundle()`**: Return `true` if the extension's emitted +steps invoke a bundled `ado-script.zip` helper (today: `gate.js`). +The compiler then emits the shared NodeTool@0 + checksum-verified +download pair **once** in the consuming job; the extension itself +emits only its `node /tmp/ado-aw-scripts/.js` invocation. +See `TriggerFiltersExtension` for the reference implementation. + To add a new runtime or tool: (1) create a directory under `src/tools/` or `src/runtimes/`, (2) implement `CompilerExtension` in `extension.rs`, (3) add a variant to the `Extension` enum and a collection check in `collect_extensions()` in `src/compile/extensions/mod.rs`. ### Filter IR (`src/compile/filter_ir.rs`) diff --git a/docs/filter-ir.md b/docs/filter-ir.md index 8d14e266..d449fef7 100644 --- a/docs/filter-ir.md +++ b/docs/filter-ir.md @@ -257,7 +257,7 @@ gate spec. export ADO_SYSTEM_ACCESS_TOKEN="$SYSTEM_ACCESSTOKEN" # 4. Run the bundled Node evaluator (downloaded by the Setup job) - node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js' + node '/tmp/ado-aw-scripts/gate.js' name: prGate displayName: "Evaluate PR filters" env: @@ -360,9 +360,9 @@ When Tier 2/3 filters are configured, the `TriggerFiltersExtension` LTS so `gate.js` has a runtime 2. **Download step** — fetches `ado-script.zip` from the ado-aw release artifacts, verifies its SHA256 checksum via `checksums.txt`, then - extracts `gate.js` to `/tmp/ado-aw-scripts/ado-script/dist/gate/index.js` + extracts `gate.js` to `/tmp/ado-aw-scripts/gate.js` 3. **Gate step** — calls `compile_gate_step_external()` to generate a step - that runs `node /tmp/ado-aw-scripts/ado-script/dist/gate/index.js` (no inline heredoc) + that runs `node /tmp/ado-aw-scripts/gate.js` (no inline heredoc) 4. **Validation** — runs `validate_pr_filters()` / `validate_pipeline_filters()` during compilation via the `validate()` trait method @@ -419,9 +419,10 @@ the ado-aw version: A `checksums.txt` file is also published at the same URL base and used to verify the SHA256 integrity of `ado-script.zip` before extraction. -The Setup-job download step pulls the zip, extracts `ado-script/dist/gate/index.js`, -and discards the rest. New per-use-site bundles follow the same pattern -(per-bundle ncc entry + per-bundle download step). +The Setup-job download step pulls the zip and extracts top-level +`gate.js` (and any sibling helpers like `prompt.js`) into +`/tmp/ado-aw-scripts/`. New per-use-site bundles follow the same pattern +(per-bundle ncc entry + the shared scripts-download step). ## Adding New Filter Types diff --git a/docs/front-matter.md b/docs/front-matter.md index cfe11625..a7a261a5 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -146,6 +146,7 @@ parameters: # optional ADO runtime parameters (surfaced in UI displayName: "Clear agent memory" type: boolean default: false +inlined-imports: false # optional opt-out from runtime prompt injection (default: false) --- @@ -154,6 +155,38 @@ parameters: # optional ADO runtime parameters (surfaced in UI Build the project and run all tests... ``` +## Prompt rendering: `inlined-imports` + +By default (`inlined-imports: false`, or unset), the agent body is +**not embedded** in the compiled YAML. Instead, the compiled pipeline +emits a small `bash` + `awk` step that, at pipeline runtime, `cat`s +the agent `.md` from the workspace, appends any extension supplements +(emitted as labelled heredocs in the same step — visible directly in +the lock yaml), strips the front matter, runs a single-pass `awk` +substitution for `${{ parameters.* }}` and `$(VAR)` patterns, and +writes the rendered prompt for the AWF sandbox. The entire pipeline +is transparent in the compiled YAML and requires no Node bundle. + +Body-only edits to the `.md` therefore no longer require an +`ado-aw compile` rebuild — only front-matter changes do. + +Set `inlined-imports: true` in front matter to opt out and restore the +legacy compile-time behaviour: the body is embedded verbatim in a +heredoc step at compile time, and extension supplements are emitted as +per-extension `cat >>` steps. Use this when: + +- The agent `.md` source path will not be resolvable inside + `$(Build.SourcesDirectory)` at runtime (e.g., compile happens + outside the trigger repo). +- The Agent pool cannot reach `github.com` for the release-asset + download. +- You need a fully self-contained compiled YAML for offline review or + archival. + +The field name matches gh-aw's equivalent knob exactly so authors +familiar with that ecosystem can reuse the same mental model. + + ## Workspace Defaults The `workspace:` field controls which directory the agent runs in. When it is diff --git a/docs/template-markers.md b/docs/template-markers.md index 2ac44d55..e361432a 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -381,9 +381,67 @@ resources: - release/* ``` -## {{ agent_content }} - -Should be replaced with the markdown body (agent instructions) extracted from the source markdown file, excluding the YAML front matter. This content provides the agent with its task description and guidelines. +## {{ prepare_agent_prompt }} + +Replaces the entire "Prepare agent prompt" step in the Agent job. + +The compiler expands this to one of two shapes depending on the +`inlined-imports:` front-matter field: + +- **`inlined-imports: false` (default — runtime rendering).** Expands + to a single inline `bash` step (no JS bundle, no Node install) that: + 1. Composes the prompt with a brace-grouped command list: + `cat "$(Build.SourcesDirectory)//agent.md"` for the body + followed by one heredoc per extension supplement + (`cat << '__ADO_AW_SUPP__EOF__' ... __ADO_AW_SUPP__EOF__`). + Every fragment is visible directly in the lock yaml. + 2. Strips the agent's YAML front matter with an `awk` block. + 3. Runs a single-pass `awk` substitution program over the body that + recognises four token shapes and resolves them in priority + order: + + | Token | Resolved via | + |------------------------------|--------------------------------------------------------| + | `\$(VAR)` / `\$(VAR.SUB)` | escape — strip backslash, leave `$(VAR)` literal | + | `${{ parameters.NAME }}` | env `ADO_AW_PARAM_` | + | `$(VAR)` / `$(VAR.SUB)` | env `` (process env) | + | `$[ ... ]` | left verbatim with a one-shot warning | + + The walk uses substring slicing rather than `gsub()` so the + replacement text is **never re-scanned**. This blocks the + "queue-with-malicious-parameter-value" chaining attack: a + parameter value that contains `$(...)` stays literal in the + rendered prompt instead of expanding against a secret env var + on a follow-up pass. + + Each declared pipeline parameter gets a separate + `ADO_AW_PARAM_: ${{ parameters. }}` env line on + the step, so the value reaches the awk script via `ENVIRON` + without being baked into the compiled YAML. + 4. Fails closed on empty output, then writes the rendered prompt + to `/tmp/awf-tools/agent-prompt.md` for the AWF sandbox. + +- **`inlined-imports: true` (opt-out).** Expands to the legacy + heredoc step that writes the markdown body verbatim into + `/tmp/awf-tools/agent-prompt.md`. Extension supplements are emitted + as separate `cat >>` steps via `wrap_prompt_append` (handled in + `{{ prepare_steps }}`). + +This marker replaces the older `{{ agent_content }}` placeholder. The +compiler resolves `{{ trigger_repo_directory }}` inside the body +path **before** emitting the step, so the `cat "$(Build.SourcesDirectory)/..."` +reference uses a fully resolved path at runtime. If the source path +cannot be expressed relative to the trigger repo (e.g., compile +invoked from outside the repo), the compiler bails with an actionable +error message pointing at `inlined-imports: true` as the escape +hatch. + +The runtime branch deliberately ships **no JS bundle**: the compose + +strip + substitute steps are pure bash + awk, both present on every +ADO image, and the entire transformation is human-readable in the +compiled YAML. See [`docs/ado-script.md`](ado-script.md) for the +contrasting `gate.js` design where a Node bundle is justified by the +much larger amount of logic involved. ## {{ mcpg_config }} diff --git a/scripts/ado-script/src/gate/predicates.ts b/scripts/ado-script/src/gate/predicates.ts index b054dd5c..9ddc4856 100644 --- a/scripts/ado-script/src/gate/predicates.ts +++ b/scripts/ado-script/src/gate/predicates.ts @@ -164,7 +164,7 @@ export function evaluatePredicate(p: PredicateSpec, facts: Map) const unknownType = (p as { type?: unknown }).type; logWarning( `Unknown predicate type '${String(unknownType)}'; failing closed. ` + - "Update scripts/ado-script/dist/gate/index.js (or the bundled ado-script.zip) to a " + + "Update the bundled ado-script.zip (which provides gate.js) to a " + "release that supports this predicate.", ); return false; diff --git a/scripts/ado-script/src/shared/types.gen.ts b/scripts/ado-script/src/shared/types.gen.ts index c674b8c9..53272e60 100644 --- a/scripts/ado-script/src/shared/types.gen.ts +++ b/scripts/ado-script/src/shared/types.gen.ts @@ -76,7 +76,7 @@ export type PredicateSpec = /** * Serializable gate specification — the JSON document consumed by the - * Node gate evaluator (`scripts/ado-script/dist/gate/index.js`) at pipeline runtime. + * Node gate evaluator (bundled as `gate.js`) at pipeline runtime. */ export interface GateSpec { checks: CheckSpec[]; diff --git a/src/compile/common.rs b/src/compile/common.rs index 36b923a8..7142755f 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -4,12 +4,16 @@ use anyhow::{Context, Result}; use std::collections::{HashMap, HashSet}; use std::path::Path; -use super::types::{CompileTarget, FrontMatter, OnConfig, PipelineParameter, PoolConfig, Repository, ReposItem}; -use super::extensions::{CompilerExtension, Extension, McpgServerConfig, McpgGatewayConfig, McpgConfig, CompileContext}; -use crate::compile::types::McpConfig; -use crate::fuzzy_schedule; +use super::extensions::{ + CompileContext, CompilerExtension, Extension, McpgConfig, McpgGatewayConfig, McpgServerConfig, +}; +use super::types::{ + CompileTarget, FrontMatter, OnConfig, PipelineParameter, PoolConfig, Repository, ReposItem, +}; use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts}; +use crate::compile::types::McpConfig; use crate::ecosystem_domains::{get_ecosystem_domains, is_ecosystem_identifier, is_known_ecosystem}; +use crate::fuzzy_schedule; use crate::validate; /// Atomically write `contents` to `path`. @@ -2081,6 +2085,15 @@ pub fn generate_setup_job( // Collect setup_steps from ALL extensions let mut ext_setup_steps: Vec = Vec::new(); + // Prepend the shared scripts-bundle install pair exactly once, if any + // extension declared a dependency on `ado-script.zip`. This keeps + // each consumer (today: gate.js in the Setup job; future helpers + // such as a queue poller could join the same dedupe) emitting only + // its own invocation — see + // `compile/extensions/mod.rs::scripts_install_steps_if_needed`. + ext_setup_steps.extend(super::extensions::scripts_install_steps_if_needed( + extensions, + )); for ext in extensions { ext_setup_steps.extend(ext.setup_steps(ctx)?); } @@ -2174,9 +2187,19 @@ pub fn generate_teardown_job( } /// Generate prepare steps (inline), including extension steps and user-defined steps. +/// +/// When `inlined_imports` is `true`, extension prompt supplements are emitted +/// as per-extension `cat >>` heredoc steps via [`wrap_prompt_append`], +/// matching the legacy embedded-prompt path. When `false` (the default), +/// supplements are instead embedded as labelled heredocs inside the +/// runtime-rendering compose step emitted by +/// [`generate_prepare_agent_prompt`], so this function omits the +/// `wrap_prompt_append` calls — emitting them again would cause double +/// inclusion in the rendered prompt. pub fn generate_prepare_steps( prepare_steps: &[serde_yaml::Value], extensions: &[super::extensions::Extension], + inlined_imports: bool, ) -> Result { let mut parts = Vec::new(); @@ -2185,7 +2208,9 @@ pub fn generate_prepare_steps( for step in ext.prepare_steps() { parts.push(step); } - if let Some(prompt) = ext.prompt_supplement() { + if inlined_imports + && let Some(prompt) = ext.prompt_supplement() + { parts.push(super::extensions::wrap_prompt_append(&prompt, ext.name())?); } } @@ -2197,6 +2222,363 @@ pub fn generate_prepare_steps( Ok(parts.join("\n\n")) } +/// Collect prompt supplements from extensions, preserving the same order +/// [`generate_prepare_steps`] would emit `wrap_prompt_append` calls in +/// (Runtimes phase first, then Tools phase, stable within each phase). +/// +/// Used by the runtime-rendering branch of +/// [`generate_prepare_agent_prompt`] to embed each supplement as its own +/// labelled heredoc inside the compose step so maintainers can read the +/// supplement text directly in the compiled YAML. +pub fn collect_prompt_supplements(extensions: &[Extension]) -> Vec { + let mut out = Vec::new(); + for ext in extensions { + if let Some(content) = ext.prompt_supplement() { + out.push(PromptSupplement { + name: ext.name().to_string(), + content, + }); + } + } + out +} + +/// In-memory representation of a single extension's prompt supplement. +/// +/// The compiler walks [`collect_prompt_supplements`] in +/// extension-phase order and embeds each entry as its own heredoc in +/// the compose step. The `name` is used only for the heredoc delimiter +/// and a debug log line; it is not rendered into the prompt. +#[derive(Debug, Clone)] +pub struct PromptSupplement { + pub name: String, + pub content: String, +} + +/// Path inside the AWF sandbox where the rendered agent prompt is written. +/// +/// Both branches of [`generate_prepare_agent_prompt`] target this path so +/// downstream engine invocations (see `compile_shared`) can reference it +/// unconditionally. +const AGENT_PROMPT_PATH: &str = "/tmp/awf-tools/agent-prompt.md"; + +/// Sanitise an extension name into a unique heredoc delimiter token +/// (ASCII alphanumeric + `_`, uppercased). The compose step has +/// multiple heredocs per extension, so collisions on the delimiter +/// would be a silent correctness bug — we validate at compile time. +fn supplement_delimiter(name: &str) -> Result { + anyhow::ensure!( + name.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_')), + "Extension name '{}' contains characters unsafe for embedding in a bash heredoc delimiter. \ + Only ASCII alphanumerics, spaces, hyphens, and underscores are allowed.", + name + ); + let sanitized: String = name + .to_uppercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + Ok(format!("__ADO_AW_SUPP_{sanitized}_EOF__")) +} + +/// Emit the YAML step that prepares `/tmp/awf-tools/agent-prompt.md` +/// for the agent. +/// +/// - When `inlined_imports` is `true`, embeds the prompt body inline +/// in a heredoc step (legacy behaviour). The body is re-indented by +/// 4 spaces so that it aligns under the bash heredoc; the leading +/// 4-space prefix is then stripped from the first line via +/// [`str::strip_prefix`] (not `trim_start_matches`, which would +/// over-strip any author-supplied leading whitespace in the first +/// prompt line). +/// - When `inlined_imports` is `false` (the default — runtime body +/// injection), emits a single bash step that: +/// 1. `cat`s the body from `source_path` (a path inside the +/// workspace), so body-only edits to the source `.md` do not +/// require recompiling the pipeline. +/// 2. Appends each [`PromptSupplement`] as a labelled heredoc, in +/// Runtimes-then-Tools extension order. Supplement content is +/// directly visible in the lock YAML. +/// 3. Strips the agent's YAML front matter via `awk`. +/// 4. Runs a single-pass `awk` substitution program that resolves +/// `${{ parameters.NAME }}` and `$(VAR)` against env vars and +/// strips `\$(...)` escapes — replacement values are looked up +/// in `ENVIRON` and inserted verbatim, never re-scanned. This +/// blocks the "queue-with-malicious-parameter-value" chaining +/// attack (a parameter value containing `$(...)` does not get +/// expanded against secrets in a follow-up pass). +/// +/// The runtime branch deliberately avoids a Node bundle: the +/// composition is pure bash + awk, both present on every ADO image, +/// and the entire step is human-readable in the compiled YAML. +pub fn generate_prepare_agent_prompt( + inlined_imports: bool, + markdown_body: &str, + source_path: &str, + supplements: Vec, + parameters: &[PipelineParameter], +) -> Result { + if inlined_imports { + // Re-indent body lines by 4 spaces to align under the heredoc, + // then strip exactly the 4-space prefix we added to the first + // line. `strip_prefix` is the correct primitive here: + // `trim_start_matches(' ')` would also strip author-supplied + // leading spaces (e.g., an indented code block as the first + // line of the prompt), which is a silent semantic change. + let body_indented = markdown_body + .lines() + .map(|l| { + if l.is_empty() { + String::new() + } else { + format!(" {l}") + } + }) + .collect::>() + .join("\n"); + let trimmed = body_indented + .strip_prefix(" ") + .unwrap_or(&body_indented); + return Ok(format!( + r#"- bash: | + # Write agent instructions to /tmp so it's accessible inside AWF container + cat > "{path}" << 'AGENT_PROMPT_EOF' + {trimmed} + AGENT_PROMPT_EOF + + echo "Agent prompt:" + cat "{path}" + displayName: "Prepare agent prompt""#, + path = AGENT_PROMPT_PATH, + )); + } + + // Runtime branch — body is read from the workspace at pipeline + // runtime. Reject any source path that won't be resolvable. + // `compile_shared` resolves `{{ trigger_repo_directory }}` before + // calling us, so a workspace-relative path always starts with + // $(Build.SourcesDirectory); a filename-only fallback (from an + // absolute input outside the trigger repo) is rejected. + if !source_path.starts_with("$(Build.SourcesDirectory)") { + anyhow::bail!( + "Cannot determine workspace-relative path for the agent .md \ + ({source_path}). The pipeline cannot read the source at \ + runtime. Either compile the agent from inside the repository \ + it triggers, or set `inlined-imports: true` in the front \ + matter to embed the prompt at compile time." + ); + } + + // Build per-supplement heredoc blocks. Each block has its own + // sanitised delimiter so multiple supplements cannot collide. + // Every content line is prefixed with the same 4-space base indent + // as the surrounding bash step so that, after the template's + // `replace_with_indent` adds the placeholder column indent, every + // line in the YAML block scalar shares the same prefix — YAML + // strips that prefix uniformly and bash sees the heredoc body + // (and its close delimiter) at column 0. + let mut supplement_blocks = String::new(); + for supp in &supplements { + let delim = supplement_delimiter(&supp.name)?; + let content = supp.content.trim_end(); + supplement_blocks.push_str(" printf '\\n\\n'\n"); + supplement_blocks.push_str(" cat << '"); + supplement_blocks.push_str(&delim); + supplement_blocks.push_str("'\n"); + for line in content.lines() { + if line.is_empty() { + supplement_blocks.push('\n'); + } else { + supplement_blocks.push_str(" "); + supplement_blocks.push_str(line); + supplement_blocks.push('\n'); + } + } + supplement_blocks.push_str(" "); + supplement_blocks.push_str(&delim); + supplement_blocks.push('\n'); + } + + // Build the awk single-pass substitution program. We walk the + // body+supplements text once, looking for any of four token + // shapes at each position: + // + // 1. \$(...) — escape (strip backslash, leave $(...) literal) + // 2. ${{ parameters.NAME }} — replace with $ADO_AW_PARAM_ + // 3. $(VAR) / $(VAR.SUB) — replace with env + // 4. $[ ... ] — leave verbatim, warn once + // + // Replacement values come from ENVIRON, never from string + // interpolation, so the awk program does NOT need to know the + // values at compile time. The list of *declared* parameter names + // IS baked in so we can distinguish "unknown parameter" from + // "declared but unset env" — see the `params` BEGIN assignment. + // + // The walk uses substring slicing rather than gsub() so the + // replacement text we return is never re-scanned for further + // matches. This is the single-pass property: a parameter value + // containing `$(...)` stays literal in the rendered prompt. + let parameter_whitelist: String = parameters + .iter() + .map(|p| format!("|{}|", p.name)) + .collect(); + + let awk_program_template = r##"BEGIN { + params = "__PARAMS_WHITELIST__" +} +{ + line = $0 + out = "" + while (length(line) > 0) { + esc_i = match(line, /\\\$\([^()]*\)/); esc_len = RLENGTH + par_i = match(line, /\$\{\{[ \t]*parameters\.[A-Za-z_][A-Za-z0-9_-]*[ \t]*\}\}/); par_len = RLENGTH + var_i = match(line, /\$\([A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?\)/); var_len = RLENGTH + expr_i = match(line, /\$\[[^\]]*\]/); expr_len = RLENGTH + + first = 0; kind = "" + if (esc_i > 0) { first = esc_i; kind = "esc" } + if (par_i > 0 && (first == 0 || par_i < first)) { first = par_i; kind = "par" } + if (var_i > 0 && (first == 0 || var_i < first)) { first = var_i; kind = "var" } + if (expr_i > 0 && (first == 0 || expr_i < first)) { first = expr_i; kind = "expr" } + + if (first == 0) { out = out line; line = ""; break } + + out = out substr(line, 1, first - 1) + + if (kind == "esc") { + out = out substr(line, first + 1, esc_len - 1) + line = substr(line, first + esc_len) + } else if (kind == "par") { + tok = substr(line, first, par_len) + match(tok, /parameters\.[A-Za-z_][A-Za-z0-9_-]*/) + ref = substr(tok, RSTART + 11, RLENGTH - 11) + if (index(params, "|" ref "|") == 0) { + print "#" "#vso[task.logissue type=warning]Unknown parameter \x27" ref "\x27; left as-is." > "/dev/stderr" + out = out tok + } else { + envk = "ADO_AW_PARAM_" toupper(ref) + gsub(/-/, "_", envk) + if (envk in ENVIRON) { + out = out ENVIRON[envk] + } else { + print "#" "#vso[task.logissue type=warning]Parameter \x27" ref "\x27 is declared but env var \x27" envk "\x27 is unset; left as-is." > "/dev/stderr" + out = out tok + } + } + line = substr(line, first + par_len) + } else if (kind == "var") { + tok = substr(line, first, var_len) + ref = substr(tok, 3, var_len - 3) + envk = toupper(ref) + gsub(/\./, "_", envk) + if (envk in ENVIRON) { + out = out ENVIRON[envk] + } else { + print "#" "#vso[task.logissue type=warning]ADO variable \x27$(" ref ")\x27 is unset (env var \x27" envk "\x27); left as-is. Secrets are not auto-exposed." > "/dev/stderr" + out = out tok + } + line = substr(line, first + var_len) + } else { + tok = substr(line, first, expr_len) + print "#" "#vso[task.logissue type=warning]Runtime expression \x27" tok "\x27 is not substituted; left as-is." > "/dev/stderr" + out = out tok + line = substr(line, first + expr_len) + } + } + print out +}"##; + + let awk_program = awk_program_template.replace("__PARAMS_WHITELIST__", ¶meter_whitelist); + + // Re-indent the awk program by 6 spaces so it aligns under the + // surrounding YAML key indentation. The first line gets the same + // prefix manually below. + let awk_indented = awk_program + .lines() + .map(|l| if l.is_empty() { String::new() } else { format!(" {l}") }) + .collect::>() + .join("\n"); + + // Parameter env mappings expose declared params to the awk script + // via ENVIRON. ADO substitutes `${{ parameters. }}` at queue + // time, so the value reaches the env without an extra hop. + let mut env_lines: Vec = Vec::new(); + for p in parameters { + let upper = p.name.to_uppercase().replace('-', "_"); + env_lines.push(format!( + " ADO_AW_PARAM_{upper}: ${{{{ parameters.{name} }}}}", + name = p.name, + )); + } + let env_block = if env_lines.is_empty() { + String::new() + } else { + format!("\n env:\n{}", env_lines.join("\n")) + }; + + // Assemble the compose step via string concatenation rather than + // a format! macro: the bash + awk script contains many `{` and + // `}` characters that would otherwise need to be doubled, and a + // doubled-brace soup is exactly the kind of opaque thing this v3 + // design is supposed to avoid. + let mut step = String::new(); + step.push_str("- bash: |\n"); + step.push_str(" set -euo pipefail\n"); + step.push_str(" OUT=\""); + step.push_str(AGENT_PROMPT_PATH); + step.push_str("\"\n"); + step.push_str(" mkdir -p \"$(dirname \"$OUT\")\"\n\n"); + + step.push_str(" # 1. Compose: body from the workspace + extension supplements.\n"); + step.push_str(" # The body lives in the trigger repo and is read fresh on every\n"); + step.push_str(" # pipeline run, so prose-only edits do not require recompiling\n"); + step.push_str(" # this pipeline.\n"); + step.push_str(" {\n"); + step.push_str(" cat \""); + step.push_str(source_path); + step.push_str("\"\n"); + // Each supplement_blocks entry contributes its own `printf` and + // `cat << 'EOF' ... EOF` lines at the same 4-space base indent as + // the surrounding step so YAML's block-scalar indent stripping + // keeps the heredoc body intact and the close delimiter at + // column 0 once bash sees it. + step.push_str(&supplement_blocks); + step.push_str(" } > \"$OUT.raw\"\n\n"); + + step.push_str(" # 2. Strip the agent's YAML front matter (everything between the\n"); + step.push_str(" # first two `---` lines on their own at the start of the file).\n"); + step.push_str(" awk 'BEGIN { skip = 0 }\n"); + step.push_str(" NR == 1 && /^---$/ { skip = 1; next }\n"); + step.push_str(" skip && /^---$/ { skip = 0; next }\n"); + step.push_str(" !skip { print }' \"$OUT.raw\" > \"$OUT.body\"\n\n"); + + step.push_str(" # 3. Single-pass substitution: `${{ parameters.NAME }}`, `$(VAR)`,\n"); + step.push_str(" # `\\$(...)` escape, `$[...]` warning. Replacement values come\n"); + step.push_str(" # from the env (ENVIRON in awk), so this step does not bake them\n"); + step.push_str(" # into the YAML. Replacement text is never re-scanned, so a\n"); + step.push_str(" # parameter value containing `$(...)` stays literal in the output.\n"); + step.push_str(" awk '"); + // The leading 6-space indent on the first line: + step.push_str(awk_indented.trim_start()); + step.push_str("' \"$OUT.body\" > \"$OUT\"\n\n"); + + step.push_str(" # 4. Fail closed on empty rendered output — front-matter-only\n"); + step.push_str(" # `.md` files are not valid agents.\n"); + step.push_str(" if [ ! -s \"$OUT\" ]; then\n"); + step.push_str(" echo \"##vso[task.logissue type=error]Rendered prompt is empty; refusing to launch agent.\"\n"); + step.push_str(" exit 1\n"); + step.push_str(" fi\n\n"); + + step.push_str(" rm -f \"$OUT.raw\" \"$OUT.body\"\n\n"); + step.push_str(" echo \"Agent prompt:\"\n"); + step.push_str(" cat \"$OUT\"\n"); + step.push_str(" displayName: \"Render agent prompt\""); + step.push_str(&env_block); + + Ok(step) +} + /// Generate finalize steps (inline) pub fn generate_finalize_steps(finalize_steps: &[serde_yaml::Value]) -> String { if finalize_steps.is_empty() { @@ -3004,7 +3386,8 @@ pub async fn compile_shared( .is_some_and(|cm| cm.is_enabled()); let parameters = build_parameters(&front_matter.parameters, has_memory); let parameters_yaml = generate_parameters(¶meters)?; - let prepare_steps = generate_prepare_steps(&front_matter.steps, extensions)?; + let prepare_steps = + generate_prepare_steps(&front_matter.steps, extensions, front_matter.inlined_imports)?; let finalize_steps = generate_finalize_steps(&front_matter.post_steps); let pr_expression = pr_filters.and_then(|f| f.expression.as_deref()); let pipeline_expression = pipeline_filters.and_then(|f| f.expression.as_deref()); @@ -3133,6 +3516,23 @@ pub async fn compile_shared( .map(|d| d.skip_integrity) .unwrap_or(false); let integrity_check = generate_integrity_check(skip_integrity); + + // Resolve `{{ trigger_repo_directory }}` inside `source_path` *before* + // we hand it to the compose-step generator: the runtime branch needs + // a literal `$(Build.SourcesDirectory)/...` path it can `cat` at + // runtime, and that path is what the AWF-aware integrity check will + // verify too. + let prompt_supplements = collect_prompt_supplements(extensions); + let resolved_source_path = + source_path.replace("{{ trigger_repo_directory }}", &trigger_repo_directory); + let prepare_agent_prompt = generate_prepare_agent_prompt( + front_matter.inlined_imports, + markdown_body, + &resolved_source_path, + prompt_supplements, + &front_matter.parameters, + )?; + let replacements: Vec<(&str, &str)> = vec![ ("{{ parameters }}", ¶meters_yaml), ("{{ compiler_version }}", compiler_version), @@ -3171,7 +3571,14 @@ pub async fn compile_shared( ("{{ trigger_repo_directory }}", &trigger_repo_directory), ("{{ working_directory }}", &working_directory), ("{{ workspace }}", &working_directory), - ("{{ agent_content }}", markdown_body), + // `{{ prepare_agent_prompt }}` expands to either the legacy + // heredoc step (when `inlined-imports: true`) or a single + // gh-aw-style bash step that `cat`s the body from the workspace, + // appends extension supplements as labelled heredocs, strips + // front matter, and runs a single-pass awk substitution + // (default). It replaces the older `{{ agent_content }}` + // placeholder. + ("{{ prepare_agent_prompt }}", &prepare_agent_prompt), ("{{ acquire_ado_token }}", &acquire_read_token), ("{{ engine_env }}", &engine_env), ("{{ engine_log_dir }}", engine_log_dir), @@ -5937,7 +6344,7 @@ safe-outputs: "---\nname: test\ndescription: test\ntools:\n cache-memory: true\n---\n", ).unwrap(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_prepare_steps(&[], &exts).unwrap(); + let result = generate_prepare_steps(&[], &exts, true).unwrap(); assert!( !result.is_empty(), "memory steps must be emitted when cache-memory enabled" @@ -5952,7 +6359,7 @@ safe-outputs: fn test_generate_prepare_steps_without_memory_and_no_steps_has_safeoutputs_prompt() { let fm = minimal_front_matter(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_prepare_steps(&[], &exts).unwrap(); + let result = generate_prepare_steps(&[], &exts, true).unwrap(); // SafeOutputs always contributes a prompt supplement assert!( result.contains("Safe Outputs"), @@ -5966,7 +6373,7 @@ safe-outputs: "---\nname: test\ndescription: test\ntools:\n cache-memory: true\n---\n", ).unwrap(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_prepare_steps(&[], &exts).unwrap(); + let result = generate_prepare_steps(&[], &exts, true).unwrap(); assert!( result.contains("DownloadPipelineArtifact"), "memory steps must include the artifact download task" @@ -5983,7 +6390,7 @@ safe-outputs: let exts = crate::compile::extensions::collect_extensions(&fm); let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello\ndisplayName: greet").unwrap(); - let result = generate_prepare_steps(&[step], &exts).unwrap(); + let result = generate_prepare_steps(&[step], &exts, true).unwrap(); assert!(!result.is_empty(), "user steps should be present"); assert!( !result.contains("agent_memory"), @@ -5999,7 +6406,7 @@ safe-outputs: let exts = crate::compile::extensions::collect_extensions(&fm); let step: serde_yaml::Value = serde_yaml::from_str("bash: echo hello\ndisplayName: greet").unwrap(); - let result = generate_prepare_steps(&[step], &exts).unwrap(); + let result = generate_prepare_steps(&[step], &exts, true).unwrap(); assert!( result.contains("agent_memory"), "memory reference must be present" @@ -6016,7 +6423,7 @@ safe-outputs: "---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n", ).unwrap(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_prepare_steps(&[], &exts).unwrap(); + let result = generate_prepare_steps(&[], &exts, true).unwrap(); assert!(result.contains("elan-init.sh"), "should include elan installer"); assert!(result.contains("Lean 4"), "should include Lean prompt"); assert!(result.contains("--default-toolchain stable"), "should default to stable"); @@ -6029,7 +6436,7 @@ safe-outputs: "---\nname: test\ndescription: test\nruntimes:\n lean:\n toolchain: \"leanprover/lean4:v4.29.1\"\n---\n", ).unwrap(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_prepare_steps(&[], &exts).unwrap(); + let result = generate_prepare_steps(&[], &exts, true).unwrap(); assert!( result.contains("--default-toolchain leanprover/lean4:v4.29.1"), "should use specified toolchain" @@ -6042,12 +6449,260 @@ safe-outputs: "---\nname: test\ndescription: test\nruntimes:\n lean: true\ntools:\n cache-memory: true\n---\n", ).unwrap(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_prepare_steps(&[], &exts).unwrap(); + let result = generate_prepare_steps(&[], &exts, true).unwrap(); assert!(result.contains("agent_memory"), "memory steps present"); assert!(result.contains("elan-init.sh"), "lean install present"); assert!(result.contains("Lean 4"), "lean prompt present"); } + #[test] + fn test_generate_prepare_steps_runtime_branch_skips_supplements() { + // With the default `inlined_imports = false`, prompt supplements + // travel via labelled heredocs inside the compose step, NOT via + // `wrap_prompt_append` `cat >>` steps. The prepare-steps string + // (which precedes the compose step) must therefore NOT contain + // the SafeOutputs supplement text — otherwise it would be + // appended twice at runtime. + let fm = minimal_front_matter(); + let exts = crate::compile::extensions::collect_extensions(&fm); + let result = generate_prepare_steps(&[], &exts, false).unwrap(); + assert!( + !result.contains("Safe Outputs"), + "runtime branch must not emit `cat >>` supplement steps; got:\n{result}" + ); + } + + // ─── generate_prepare_agent_prompt ──────────────────────────────────────── + + #[test] + fn test_prepare_agent_prompt_runtime_cats_body_from_workspace() { + let yaml = generate_prepare_agent_prompt( + false, + "Hello world", + "$(Build.SourcesDirectory)/agents/x.md", + vec![], + &[], + ) + .unwrap(); + assert!( + yaml.contains(r#"cat "$(Build.SourcesDirectory)/agents/x.md""#), + "runtime branch must cat the body from the workspace: {yaml}" + ); + assert!( + yaml.contains("Render agent prompt"), + "runtime branch must label step `Render agent prompt`: {yaml}" + ); + // The body content is NOT inlined into the YAML — it stays on disk. + assert!( + !yaml.contains("Hello world"), + "runtime branch must not embed body verbatim: {yaml}" + ); + // Single bash step, no Node, no scripts.zip download. + assert!( + !yaml.contains("NodeTool@0"), + "runtime branch must NOT install Node for prompt rendering: {yaml}" + ); + assert!( + !yaml.contains("ado-script.zip"), + "runtime branch must NOT download the scripts bundle: {yaml}" + ); + assert!( + !yaml.contains("prompt.js"), + "runtime branch must NOT invoke a JS bundle: {yaml}" + ); + } + + #[test] + fn test_prepare_agent_prompt_runtime_emits_awk_substitution() { + let yaml = generate_prepare_agent_prompt( + false, + "Hello", + "$(Build.SourcesDirectory)/x.md", + vec![], + &[], + ) + .unwrap(); + // Front-matter strip via awk. + assert!( + yaml.contains("Strip the agent's YAML front matter"), + "runtime branch must include the awk front-matter strip step: {yaml}" + ); + assert!( + yaml.contains("NR == 1 && /^---$/"), + "runtime branch must use the awk front-matter sentinel: {yaml}" + ); + // Substitution via awk single-pass program. + assert!( + yaml.contains("Single-pass substitution"), + "runtime branch must include the substitution comment: {yaml}" + ); + assert!( + yaml.contains("ENVIRON"), + "runtime branch must read replacement values from ENVIRON: {yaml}" + ); + } + + #[test] + fn test_prepare_agent_prompt_inlined_embeds_body_in_heredoc() { + let yaml = generate_prepare_agent_prompt( + true, + "Hello world", + "agents/x.md", + vec![], + &[], + ) + .unwrap(); + assert!( + yaml.contains("AGENT_PROMPT_EOF"), + "inlined branch must use heredoc: {yaml}" + ); + assert!( + yaml.contains("Hello world"), + "inlined branch must embed body verbatim: {yaml}" + ); + assert!( + !yaml.contains("Render agent prompt"), + "inlined branch must not use the runtime step name: {yaml}" + ); + } + + #[test] + fn test_prepare_agent_prompt_inlined_preserves_first_line_indentation() { + // Regression: an earlier implementation used + // `trim_start_matches(' ')` to strip the 4-space prefix we + // inject for heredoc alignment. That call swallows ALL leading + // spaces from the joined body, silently de-indenting any + // intentionally-indented opening line (e.g., a code block as + // the first line of the prompt). The correct primitive is + // `strip_prefix(" ")`. + let body = " indented opening\nplain line"; + let yaml = generate_prepare_agent_prompt(true, body, "x.md", vec![], &[]).unwrap(); + assert!( + yaml.contains("indented opening"), + "first line must survive: {yaml}" + ); + assert!( + yaml.contains(" indented opening"), + "author's leading spaces must survive (not over-stripped): {yaml}" + ); + } + + #[test] + fn test_prepare_agent_prompt_runtime_rejects_non_workspace_source() { + // Filename-only / absolute-path source can't be read at runtime. + let err = generate_prepare_agent_prompt( + false, + "Hello", + "/absolute/elsewhere.md", + vec![], + &[], + ) + .unwrap_err(); + let msg = format!("{:#}", err); + assert!( + msg.contains("inlined-imports: true"), + "error must mention the escape hatch: {msg}" + ); + } + + #[test] + fn test_prepare_agent_prompt_runtime_emits_param_env_mappings() { + let params = vec![ + crate::compile::types::PipelineParameter { + name: "target-branch".into(), + display_name: None, + param_type: None, + default: None, + values: None, + }, + crate::compile::types::PipelineParameter { + name: "verbose".into(), + display_name: None, + param_type: None, + default: None, + values: None, + }, + ]; + let yaml = generate_prepare_agent_prompt( + false, + "Hello", + "$(Build.SourcesDirectory)/x.md", + vec![], + ¶ms, + ) + .unwrap(); + assert!( + yaml.contains("ADO_AW_PARAM_TARGET_BRANCH: ${{ parameters.target-branch }}"), + "must emit env mapping for `target-branch`: {yaml}" + ); + assert!( + yaml.contains("ADO_AW_PARAM_VERBOSE: ${{ parameters.verbose }}"), + "must emit env mapping for `verbose`: {yaml}" + ); + } + + #[test] + fn test_prepare_agent_prompt_runtime_embeds_supplement_as_heredoc() { + // Extension supplements should appear as labelled heredocs inside + // the compose step so maintainers can read them in the lock yaml. + let supps = vec![PromptSupplement { + name: "SafeOutputs".into(), + content: "## SafeOutputs preamble".into(), + }]; + let yaml = generate_prepare_agent_prompt( + false, + "Hello", + "$(Build.SourcesDirectory)/x.md", + supps, + &[], + ) + .unwrap(); + assert!( + yaml.contains("__ADO_AW_SUPP_SAFEOUTPUTS_EOF__"), + "supplement must use a sanitised heredoc delimiter: {yaml}" + ); + assert!( + yaml.contains("## SafeOutputs preamble"), + "supplement content must be visible in the compose step: {yaml}" + ); + } + + // ─── collect_prompt_supplements ───────────────────────────────────────── + + #[test] + fn test_collect_prompt_supplements_safeoutputs_only() { + let fm = minimal_front_matter(); + let exts = crate::compile::extensions::collect_extensions(&fm); + let supps = collect_prompt_supplements(&exts); + assert_eq!( + supps.len(), + 1, + "minimal front matter activates SafeOutputs only" + ); + assert_eq!(supps[0].name, "SafeOutputs"); + assert!(supps[0].content.contains("Safe Outputs")); + } + + #[test] + fn test_collect_prompt_supplements_runtimes_before_tools() { + // Lean is a runtime; SafeOutputs is a tool. Runtimes must + // appear first in the supplements list (matches + // `generate_prepare_steps` ordering policy). + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n", + ) + .unwrap(); + let exts = crate::compile::extensions::collect_extensions(&fm); + let supps = collect_prompt_supplements(&exts); + assert!(supps.len() >= 2, "should have at least Lean + SafeOutputs"); + let lean_idx = supps.iter().position(|s| s.name.contains("Lean")).unwrap(); + let so_idx = supps.iter().position(|s| s.name == "SafeOutputs").unwrap(); + assert!( + lean_idx < so_idx, + "runtime supplement must precede tool supplement: {supps:?}" + ); + } + // ─── generate_awf_mounts ────────────────────────────────────────────── #[test] diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 1d2964f3..15e4ad92 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -355,6 +355,19 @@ pub trait CompilerExtension { fn agent_env_vars(&self) -> Vec<(String, String)> { vec![] } + + /// Whether this extension needs the `ado-script.zip` bundle (containing + /// `gate.js`, `prompt.js`, …) to be installed at pipeline runtime. + /// + /// When any active extension returns `true`, the compiler emits a + /// single shared install block (NodeTool@0 + checksum-verified + /// download + unzip) at the start of the consuming job, and each + /// consumer simply invokes `node /tmp/ado-aw-scripts/.js`. + /// Extensions that flag this **must not** emit their own install + /// steps — emit only the bundle invocation in their own steps. + fn needs_scripts_bundle(&self) -> bool { + false + } } /// Mount access mode for an AWF bind mount. @@ -568,6 +581,9 @@ macro_rules! extension_enum { fn agent_env_vars(&self) -> Vec<(String, String)> { match self { $( $Enum::$Variant(e) => e.agent_env_vars(), )+ } } + fn needs_scripts_bundle(&self) -> bool { + match self { $( $Enum::$Variant(e) => e.needs_scripts_bundle(), )+ } + } } }; } @@ -746,3 +762,90 @@ pub fn wrap_prompt_append(content: &str, display_name: &str) -> Result { #[cfg(test)] mod tests; + +// ──────────────────────────────────────────────────────────────────── +// Shared scripts-bundle install helpers +// ──────────────────────────────────────────────────────────────────── + +/// Directory the `ado-script.zip` bundle is extracted into at pipeline +/// runtime. Top-level helpers (`gate.js`, `prompt.js`, …) live directly +/// under this path — see `SCRIPTS_BUNDLE_DIR/.js`. +pub const SCRIPTS_BUNDLE_DIR: &str = "/tmp/ado-aw-scripts"; + +/// Absolute URL prefix for release artifacts shipped with this compiler +/// version. +pub const RELEASE_BASE_URL: &str = "https://github.com/githubnext/ado-aw/releases/download"; + +/// Emit the `NodeTool@0` step that installs Node 20.x LTS. +/// +/// Pinned to LTS major; the bundled scripts only need basic Node +/// features, so any 20.x patch is acceptable. Capped at 5 minutes so a +/// stalled image-install does not block the entire pipeline until the +/// job-level timeout fires. +pub fn node_tool_step(display_name: &str) -> String { + format!( + r#"- task: NodeTool@0 + inputs: + versionSpec: "20.x" + displayName: "{display_name}" + timeoutInMinutes: 5 + condition: succeeded()"#, + ) +} + +/// Emit the `bash:` step that downloads `ado-script.zip`, verifies its +/// SHA-256 against the `checksums.txt` release asset, then extracts it +/// into [`SCRIPTS_BUNDLE_DIR`]. +/// +/// The compiler version is embedded as the release tag, so each +/// compiled pipeline downloads its compiler-version-matched zip and +/// there is no cross-version contract. The same 5-minute cap as +/// [`node_tool_step`] bounds curl + sha256sum + unzip. +pub fn scripts_download_step() -> String { + let version = env!("CARGO_PKG_VERSION"); + format!( + r#"- bash: | + set -eo pipefail + mkdir -p {dir} + curl -fsSL "{base}/v{version}/checksums.txt" -o {dir}/checksums.txt + curl -fsSL "{base}/v{version}/ado-script.zip" -o {dir}/ado-script.zip + cd {dir} && grep "ado-script.zip" checksums.txt | sha256sum -c - + unzip -o {dir}/ado-script.zip -d {dir}/ + displayName: "Download ado-aw scripts (v{version})" + timeoutInMinutes: 5 + condition: succeeded()"#, + dir = SCRIPTS_BUNDLE_DIR, + base = RELEASE_BASE_URL, + version = version, + ) +} + +/// Build the runtime path for a bundle name (e.g. `bundle_path("gate")` +/// → `"/tmp/ado-aw-scripts/gate.js"`). +/// +/// Currently unused now that the prompt path is pure bash + awk and +/// doesn't reference a JS bundle. Kept as a reusable helper for any +/// future bundle consumer. +#[allow(dead_code)] +pub fn bundle_path(name: &str) -> String { + format!("{SCRIPTS_BUNDLE_DIR}/{name}.js") +} + +/// Returns the two-step install bundle ([`node_tool_step`] + +/// [`scripts_download_step`]) when any of the supplied extensions need +/// the scripts bundle, otherwise an empty `Vec`. +/// +/// Today only the gate-evaluator path consumes the bundle; future +/// helpers (e.g. a queue poller) can join the same dedupe by +/// returning `true` from [`CompilerExtension::needs_scripts_bundle`]. +pub fn scripts_install_steps_if_needed(extensions: &[Extension]) -> Vec { + if extensions.iter().any(|e| e.needs_scripts_bundle()) { + vec![ + node_tool_step("Install Node.js 20.x for ado-aw scripts"), + scripts_download_step(), + ] + } else { + vec![] + } +} + diff --git a/src/compile/extensions/trigger_filters.rs b/src/compile/extensions/trigger_filters.rs index 2647a88b..4230bbc6 100644 --- a/src/compile/extensions/trigger_filters.rs +++ b/src/compile/extensions/trigger_filters.rs @@ -1,9 +1,11 @@ //! Trigger filters compiler extension. //! //! Activates when any `filters:` configuration is present under `on.pr` -//! or `on.pipeline`. Injects into the Setup job: (1) a Node install step, -//! (2) a download step for the gate evaluator scripts bundle, and (3) the -//! gate step that evaluates the filter spec via the Node evaluator. +//! or `on.pipeline`. Injects the gate step that evaluates the filter +//! spec via the Node evaluator into the Setup job, and declares a need +//! for the shared `ado-script.zip` bundle so the compiler emits a +//! single `NodeTool@0` + download/extract pair shared with any other +//! bundle consumers (e.g., the runtime prompt renderer). //! //! All filter types (simple and complex) are evaluated by the Node //! evaluator — there is no inline bash codegen path. @@ -17,11 +19,8 @@ use crate::compile::filter_ir::{ }; use crate::compile::types::{PipelineFilters, PrFilters}; -/// The path where the gate evaluator is downloaded at pipeline runtime. -const GATE_EVAL_PATH: &str = "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js"; - -/// Base URL for ado-aw release artifacts. -const RELEASE_BASE_URL: &str = "https://github.com/githubnext/ado-aw/releases/download"; +/// The path where the gate evaluator is invoked at pipeline runtime. +const GATE_EVAL_PATH: &str = "/tmp/ado-aw-scripts/gate.js"; /// Compiler extension that delivers and runs the gate evaluator for /// complex trigger filters. @@ -63,7 +62,6 @@ impl CompilerExtension for TriggerFiltersExtension { } fn setup_steps(&self, _ctx: &CompileContext) -> Result> { - let version = env!("CARGO_PKG_VERSION"); let mut gate_steps = Vec::new(); // PR gate step @@ -90,49 +88,26 @@ impl CompilerExtension for TriggerFiltersExtension { } } - // Only download scripts when we actually have gate steps - if gate_steps.is_empty() { - return Ok(vec![]); - } - - let mut steps = Vec::new(); - - // Install Node 20.x for the gate evaluator. Pin to LTS major; ado-aw - // only requires basic Node features, so any 20.x patch release is - // acceptable. NodeTool@0 is preinstalled on Microsoft-hosted and 1ES - // images. A 5-minute timeout caps the worst-case cold-image install - // — a hung Node install would otherwise block the entire pipeline - // until the agent-level job timeout (often hours) fires. - steps.push( - r#"- task: NodeTool@0 - inputs: - versionSpec: "20.x" - displayName: "Install Node.js 20.x for gate evaluator" - timeoutInMinutes: 5 - condition: succeeded()"# - .to_string(), - ); - - // Same rationale for the download/extract step: bound the - // curl + sha256sum + unzip pipeline so a stalled CDN response - // doesn't tie up the whole pipeline. The unzip command also - // passes `-d` explicitly as a belt-and-suspenders zip-slip - // hardening on top of the sha256 verification above. - steps.push(format!( - r#"- bash: | - set -eo pipefail - mkdir -p /tmp/ado-aw-scripts - curl -fsSL "{RELEASE_BASE_URL}/v{version}/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "{RELEASE_BASE_URL}/v{version}/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 (v{version})" - timeoutInMinutes: 5 - condition: succeeded()"#, - )); - steps.extend(gate_steps); + // The NodeTool@0 + scripts-download steps are NOT emitted here: + // they are inserted once by the compiler when any extension + // returns `needs_scripts_bundle() == true`. See + // `super::scripts_install_steps_if_needed`. + Ok(gate_steps) + } - Ok(steps) + fn needs_scripts_bundle(&self) -> bool { + // The bundle is only needed if we actually emit a gate step. + let has_pr = self + .pr_filters + .as_ref() + .map(|f| !lower_pr_filters(f).is_empty()) + .unwrap_or(false); + let has_pipeline = self + .pipeline_filters + .as_ref() + .map(|f| !lower_pipeline_filters(f).is_empty()) + .unwrap_or(false); + has_pr || has_pipeline } fn validate(&self, _ctx: &CompileContext) -> Result> { @@ -225,7 +200,10 @@ mod tests { } #[test] - fn test_setup_steps_includes_download_and_gate() { + fn test_setup_steps_emits_only_gate_step() { + // After dedupe, setup_steps emits ONLY the gate step. The + // NodeTool@0 + scripts-download pair is hoisted into a + // compiler-level emission gated on needs_scripts_bundle(). let filters = PrFilters { labels: Some(LabelFilter { any_of: vec!["run-agent".into()], @@ -240,43 +218,38 @@ mod tests { let steps = ext.setup_steps(&ctx).unwrap(); assert_eq!( steps.len(), - 3, - "should have Node install + download + gate step" - ); - assert!( - steps[0].contains("NodeTool@0"), - "first step should install Node" + 1, + "setup_steps should only emit the gate step now that install is dedup'd: {steps:?}" ); - assert!(steps[0].contains("20.x"), "should install Node 20.x"); - assert!(steps[1].contains("curl"), "second step should download"); assert!( - steps[1].contains("ado-script.zip"), - "should download ado-script.zip" + steps[0].contains("prGate"), + "step should be the PR gate" ); assert!( - steps[1].contains("checksums.txt"), - "should download checksums.txt" - ); - assert!( - steps[1].contains("sha256sum -c -"), - "should verify ado-script.zip checksum" + steps[0].contains("node '/tmp/ado-aw-scripts/gate.js'"), + "gate step should reference external script" ); + // No install/download leakage from this extension after dedupe. assert!( - steps[1].contains("unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/"), - "should extract ado-script.zip into the explicit target dir" + !steps[0].contains("NodeTool@0"), + "extension should NOT emit NodeTool@0 (compiler emits it once)" ); assert!( - steps[0].contains("timeoutInMinutes: 5"), - "Node install step should bound runtime" + !steps[0].contains("ado-script.zip"), + "extension should NOT emit the download (compiler emits it once)" ); assert!( - steps[1].contains("timeoutInMinutes: 5"), - "Download step should bound runtime" + ext.needs_scripts_bundle(), + "extension must declare bundle dependency so compiler emits install" ); - assert!(steps[2].contains("prGate"), "third step should be PR gate"); + } + + #[test] + fn test_needs_scripts_bundle_false_without_filters() { + let ext = TriggerFiltersExtension::new(None, None); assert!( - steps[2].contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'"), - "gate step should reference external script" + !ext.needs_scripts_bundle(), + "no filters → no gate step → no bundle dependency" ); } diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index fe3cf419..6bd06252 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -818,7 +818,7 @@ use schemars::JsonSchema; use serde::Serialize; /// Serializable gate specification — the JSON document consumed by the -/// Node gate evaluator (`scripts/ado-script/dist/gate/index.js`) at pipeline runtime. +/// Node gate evaluator (bundled as `gate.js`) at pipeline runtime. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct GateSpec { pub context: GateContextSpec, @@ -1534,7 +1534,7 @@ mod tests { let result = compile_gate_step_external( GateContext::PullRequest, &[], - "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + "/tmp/ado-aw-scripts/gate.js", ) .unwrap(); assert!(result.is_empty()); @@ -1553,7 +1553,7 @@ mod tests { let result = compile_gate_step_external( GateContext::PullRequest, &checks, - "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + "/tmp/ado-aw-scripts/gate.js", ) .unwrap(); assert!(result.contains("- bash:"), "should be a bash step"); @@ -1562,7 +1562,7 @@ mod tests { "should include base64 spec in env" ); assert!( - result.contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'"), + result.contains("node '/tmp/ado-aw-scripts/gate.js'"), "should reference external evaluator script" ); assert!(result.contains("name: prGate"), "should set step name"); @@ -1585,7 +1585,7 @@ mod tests { let result = compile_gate_step_external( GateContext::PullRequest, &checks, - "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + "/tmp/ado-aw-scripts/gate.js", ) .unwrap(); assert!( @@ -1612,7 +1612,7 @@ mod tests { let result = compile_gate_step_external( GateContext::PipelineCompletion, &checks, - "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + "/tmp/ado-aw-scripts/gate.js", ) .unwrap(); assert!( @@ -1642,7 +1642,7 @@ mod tests { let result = compile_gate_step_external( GateContext::PullRequest, &checks, - "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + "/tmp/ado-aw-scripts/gate.js", ) .unwrap(); assert!( @@ -1668,7 +1668,7 @@ mod tests { let result = compile_gate_step_external( GateContext::PullRequest, &checks, - "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + "/tmp/ado-aw-scripts/gate.js", ) .unwrap(); // Check export lines only (evaluator script always contains these strings) @@ -1761,7 +1761,7 @@ mod tests { let step = compile_gate_step_external( GateContext::PullRequest, &checks, - "/tmp/ado-aw-scripts/ado-script/dist/gate/index.js", + "/tmp/ado-aw-scripts/gate.js", ) .unwrap(); // Step structure diff --git a/src/compile/types.rs b/src/compile/types.rs index 02acc7ee..47ac4344 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -692,6 +692,22 @@ pub struct FrontMatter { /// Runtime parameters for the pipeline (surfaced in ADO UI when queuing a run) #[serde(default)] pub parameters: Vec, + /// Opt out of runtime prompt injection. + /// + /// Default (`false`) ships the agent body **out** of the compiled + /// pipeline YAML and lets `prompt.js` read it from the workspace at + /// runtime, so body-only edits to the source `.md` no longer require + /// recompiling the pipeline. + /// + /// Setting `inlined-imports: true` restores the legacy behaviour: + /// the body is embedded verbatim in a heredoc step at compile time + /// and extension prompt supplements are emitted as `cat >>` steps. + /// Use this for self-contained YAML, restricted networks where + /// `github.com` is unreachable from the Agent pool, or when the + /// source `.md` path cannot be resolved relative to the trigger + /// repo. Matches gh-aw's field name exactly. + #[serde(default, rename = "inlined-imports")] + pub inlined_imports: bool, } impl FrontMatter { @@ -2042,6 +2058,72 @@ Body ); } + // ─── inlined-imports field ──────────────────────────────────────────────── + + #[test] + fn test_inlined_imports_defaults_to_false() { + let content = r#"--- +name: "Test" +description: "Test" +--- + +Body +"#; + let (fm, _) = super::super::common::parse_markdown(content).unwrap(); + assert!( + !fm.inlined_imports, + "inlined-imports should default to false (runtime prompt injection is the default)" + ); + } + + #[test] + fn test_inlined_imports_accepts_true() { + let content = r#"--- +name: "Test" +description: "Test" +inlined-imports: true +--- + +Body +"#; + let (fm, _) = super::super::common::parse_markdown(content).unwrap(); + assert!(fm.inlined_imports); + } + + #[test] + fn test_inlined_imports_accepts_false_explicit() { + let content = r#"--- +name: "Test" +description: "Test" +inlined-imports: false +--- + +Body +"#; + let (fm, _) = super::super::common::parse_markdown(content).unwrap(); + assert!(!fm.inlined_imports); + } + + #[test] + fn test_inlined_imports_uses_hyphen_not_underscore() { + // Snake-case `inlined_imports:` must NOT be accepted; the canonical + // YAML key is `inlined-imports:` (matches gh-aw and the rest of the + // front-matter naming convention). + let content = r#"--- +name: "Test" +description: "Test" +inlined_imports: true +--- + +Body +"#; + let result = super::super::common::parse_markdown(content); + assert!( + result.is_err(), + "underscored alias should be rejected by deny_unknown_fields" + ); + } + // ─── PrTriggerConfig deserialization ───────────────────────────────────── // NOTE: These tests use `triggers:` as a wrapper key and deserialize // OnConfig directly (not through FrontMatter). They test struct diff --git a/src/data/1es-base.yml b/src/data/1es-base.yml index 3fd1b032..3c5b1e1e 100644 --- a/src/data/1es-base.yml +++ b/src/data/1es-base.yml @@ -127,15 +127,7 @@ extends: cp "$(Agent.TempDirectory)/staging/mcpg-config.json" /tmp/awf-tools/staging/mcpg-config.json displayName: "Prepare tooling" - - bash: | - # Write agent instructions to /tmp so it's accessible inside AWF container - cat > "/tmp/awf-tools/agent-prompt.md" << 'AGENT_PROMPT_EOF' - {{ agent_content }} - AGENT_PROMPT_EOF - - echo "Agent prompt:" - cat "/tmp/awf-tools/agent-prompt.md" - displayName: "Prepare agent prompt" + {{ prepare_agent_prompt }} - task: DockerInstaller@0 displayName: "Install Docker" diff --git a/src/data/base.yml b/src/data/base.yml index 779905ed..ac607d49 100644 --- a/src/data/base.yml +++ b/src/data/base.yml @@ -99,15 +99,7 @@ jobs: cp "$(Agent.TempDirectory)/staging/mcpg-config.json" /tmp/awf-tools/staging/mcpg-config.json displayName: "Prepare tooling" - - bash: | - # Write agent instructions to /tmp so it's accessible inside AWF container - cat > "/tmp/awf-tools/agent-prompt.md" << 'AGENT_PROMPT_EOF' - {{ agent_content }} - AGENT_PROMPT_EOF - - echo "Agent prompt:" - cat "/tmp/awf-tools/agent-prompt.md" - displayName: "Prepare agent prompt" + {{ prepare_agent_prompt }} - task: DockerInstaller@0 displayName: "Install Docker" diff --git a/src/data/job-base.yml b/src/data/job-base.yml index fb94231e..37725298 100644 --- a/src/data/job-base.yml +++ b/src/data/job-base.yml @@ -86,15 +86,7 @@ jobs: cp "$(Agent.TempDirectory)/staging/mcpg-config.json" /tmp/awf-tools/staging/mcpg-config.json displayName: "Prepare tooling" - - bash: | - # Write agent instructions to /tmp so it's accessible inside AWF container - cat > "/tmp/awf-tools/agent-prompt.md" << 'AGENT_PROMPT_EOF' - {{ agent_content }} - AGENT_PROMPT_EOF - - echo "Agent prompt:" - cat "/tmp/awf-tools/agent-prompt.md" - displayName: "Prepare agent prompt" + {{ prepare_agent_prompt }} - task: DockerInstaller@0 displayName: "Install Docker" diff --git a/src/data/stage-base.yml b/src/data/stage-base.yml index 8de31813..029c9182 100644 --- a/src/data/stage-base.yml +++ b/src/data/stage-base.yml @@ -90,15 +90,7 @@ stages: cp "$(Agent.TempDirectory)/staging/mcpg-config.json" /tmp/awf-tools/staging/mcpg-config.json displayName: "Prepare tooling" - - bash: | - # Write agent instructions to /tmp so it's accessible inside AWF container - cat > "/tmp/awf-tools/agent-prompt.md" << 'AGENT_PROMPT_EOF' - {{ agent_content }} - AGENT_PROMPT_EOF - - echo "Agent prompt:" - cat "/tmp/awf-tools/agent-prompt.md" - displayName: "Prepare agent prompt" + {{ prepare_agent_prompt }} - task: DockerInstaller@0 displayName: "Install Docker" diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 1ced57d8..0885e209 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -4082,7 +4082,7 @@ fn test_pr_filter_tier1_has_evaluator_gate() { "Should include base64-encoded spec" ); assert!( - compiled.contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'"), + compiled.contains("node '/tmp/ado-aw-scripts/gate.js'"), "Should invoke node gate evaluator" ); assert!( @@ -4120,7 +4120,7 @@ fn test_pr_filter_tier2_has_extension_gate() { "Tier 2 should include base64-encoded spec" ); assert!( - compiled.contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'"), + compiled.contains("node '/tmp/ado-aw-scripts/gate.js'"), "Tier 2 should invoke node gate evaluator" ); assert!(compiled.contains("name: prGate"), "Should have prGate step"); @@ -4359,3 +4359,110 @@ fn test_example_dogfood_failure_reporter_structure() { "Example should target githubnext/ado-aw" ); } + +// ─── Runtime prompt injection (gh-aw-style composable compose step) ───────── + +/// Default mode: body is NOT embedded in the compiled YAML; instead the +/// compose step `cat`s it from `$(Build.SourcesDirectory)/.md` at +/// pipeline runtime. Body-only edits to the source `.md` therefore do not +/// require recompiling the pipeline. +#[test] +fn test_runtime_prompt_default_cats_body_from_workspace() { + let compiled = compile_fixture("runtime-prompt-default-agent.md"); + + assert!( + compiled.contains(r#"cat "$(Build.SourcesDirectory)/runtime-prompt-default-agent.md""#), + "default path must cat the body from the workspace: \n{compiled}" + ); + assert!( + compiled.contains("Render agent prompt"), + "default path must label step `Render agent prompt`: \n{compiled}" + ); + // No JS bundle, no Node download, no opaque base64 spec — the + // whole compose step is plain bash + awk, visible in the lock yaml. + assert!( + !compiled.contains("prompt.js"), + "default path must NOT invoke a JS prompt bundle: \n{compiled}" + ); + assert!( + !compiled.contains("ADO_AW_PROMPT_SPEC"), + "default path must NOT emit a base64 prompt spec: \n{compiled}" + ); + assert!( + !compiled.contains("Install Node.js 20.x for prompt renderer"), + "default path must NOT install Node for prompt rendering: \n{compiled}" + ); +} + +/// Default mode: the compose step strips front matter via awk and runs +/// the single-pass substitution awk program over the assembled prompt. +#[test] +fn test_runtime_prompt_default_uses_awk_strip_and_substitute() { + let compiled = compile_fixture("runtime-prompt-default-agent.md"); + + // Front-matter strip + assert!( + compiled.contains("NR == 1 && /^---$/"), + "default path must use the awk front-matter sentinel: \n{compiled}" + ); + // Substitution program reads ENVIRON (no compile-time-baked values). + assert!( + compiled.contains("ENVIRON[envk]"), + "default path must read replacement values from ENVIRON: \n{compiled}" + ); + // Token shapes the substitution recognises. + assert!( + compiled.contains(r"\\\$\("), + "default path must recognise the `\\$(...)` escape token: \n{compiled}" + ); + assert!( + compiled.contains(r"parameters\."), + "default path must recognise `${{ parameters.* }}`: \n{compiled}" + ); +} + +/// Default mode: the verbatim body line from the fixture must be ABSENT. +#[test] +fn test_runtime_prompt_default_omits_body_from_yaml() { + let compiled = compile_fixture("runtime-prompt-default-agent.md"); + + // A line that appears only in the body of the fixture. + let body_marker = "must NOT appear verbatim in the compiled YAML"; + assert!( + !compiled.contains(body_marker), + "compiled YAML must not contain the prompt body verbatim (defeats the runtime injection point): \n{compiled}" + ); + // Belt-and-suspenders: the legacy heredoc marker must not appear. + assert!( + !compiled.contains("AGENT_PROMPT_EOF"), + "default path must not emit the heredoc marker: \n{compiled}" + ); +} + +/// Inlined mode (`inlined-imports: true`): body is embedded verbatim in a +/// heredoc step and no awk substitution program is emitted. +#[test] +fn test_runtime_prompt_inlined_embeds_body_in_heredoc() { + let compiled = compile_fixture("runtime-prompt-inlined-agent.md"); + + let body_marker = "MUST appear verbatim in the compiled YAML"; + assert!( + compiled.contains(body_marker), + "inlined-imports: true must embed the body in the YAML: \n{compiled}" + ); + assert!( + compiled.contains("AGENT_PROMPT_EOF"), + "inlined branch must use the heredoc marker: \n{compiled}" + ); + assert!( + !compiled.contains("prompt.js"), + "inlined branch must NOT invoke a JS bundle: \n{compiled}" + ); + assert!( + !compiled.contains("Render agent prompt"), + "inlined branch must NOT use the runtime step name: \n{compiled}" + ); +} + + + diff --git a/tests/fixtures/runtime-prompt-default-agent.md b/tests/fixtures/runtime-prompt-default-agent.md new file mode 100644 index 00000000..b0bd9672 --- /dev/null +++ b/tests/fixtures/runtime-prompt-default-agent.md @@ -0,0 +1,11 @@ +--- +name: "Runtime Prompt Default Agent" +description: "Fixture exercising the default (runtime) prompt-injection path" +--- + +## Runtime Prompt Test Body + +This body must NOT appear verbatim in the compiled YAML when +`inlined-imports` is unset (the default). Instead, `prompt.js` reads it +from the workspace at pipeline runtime and the body is delivered to +the agent via `/tmp/awf-tools/agent-prompt.md`. diff --git a/tests/fixtures/runtime-prompt-inlined-agent.md b/tests/fixtures/runtime-prompt-inlined-agent.md new file mode 100644 index 00000000..c3a8eea1 --- /dev/null +++ b/tests/fixtures/runtime-prompt-inlined-agent.md @@ -0,0 +1,10 @@ +--- +name: "Runtime Prompt Inlined Agent" +description: "Fixture exercising the legacy (inlined) prompt path" +inlined-imports: true +--- + +## Inlined Prompt Test Body + +This body MUST appear verbatim in the compiled YAML (inside a +heredoc-delimited `cat >` step) when `inlined-imports: true` is set. diff --git a/tests/gate_e2e.rs b/tests/gate_e2e.rs index 4845f1e8..ea57b17d 100644 --- a/tests/gate_e2e.rs +++ b/tests/gate_e2e.rs @@ -22,7 +22,7 @@ fn find_gate_spec(value: &Value) -> Option { match value { Value::Mapping(mapping) => { let script = string_field(mapping, "bash").or_else(|| string_field(mapping, "script")); - if script.is_some_and(|script| script.contains("node '/tmp/ado-aw-scripts/ado-script/dist/gate/index.js'")) { + if script.is_some_and(|script| script.contains("node '/tmp/ado-aw-scripts/gate.js'")) { let env = value_field(mapping, "env")?.as_mapping()?; return string_field(env, "GATE_SPEC").map(str::to_owned); }