From 0ff06f33057dbe4aa70c5830e49af20de930da2d Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 18 May 2026 14:18:11 +0100 Subject: [PATCH] feat(compile): runtime prompt injection via prompt.js bundle Default behaviour now renders the agent prompt at pipeline runtime via a new prompt.js ado-script bundle, instead of embedding the body in the compiled YAML. Body-only edits to the agent .md no longer require recompiling the pipeline. Set 'inlined-imports: true' in front matter to opt out and keep the legacy heredoc-embedded behaviour. Supersedes #395. Built fresh on current main; folds in the post-#389 gate.js infrastructure and addresses the bot-review findings from the original PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ado-script.yml | 17 +- .github/workflows/release.yml | 14 +- AGENTS.md | 11 +- docs/ado-script.md | 157 +++++-- docs/extending.md | 19 + docs/filter-ir.md | 13 +- docs/front-matter.md | 29 ++ docs/template-markers.md | 43 +- scripts/ado-script/package.json | 9 +- scripts/ado-script/src/gate/predicates.ts | 2 +- .../src/prompt/__tests__/frontmatter.test.ts | 58 +++ .../src/prompt/__tests__/substitute.test.ts | 175 +++++++ scripts/ado-script/src/prompt/frontmatter.ts | 53 +++ scripts/ado-script/src/prompt/index.ts | 107 +++++ scripts/ado-script/src/prompt/substitute.ts | 127 ++++++ .../ado-script/src/shared/types-prompt.gen.ts | 49 ++ scripts/ado-script/src/shared/types.gen.ts | 2 +- scripts/ado-script/test/prompt-smoke.test.ts | 142 ++++++ src/compile/common.rs | 431 +++++++++++++++++- src/compile/extensions/mod.rs | 98 ++++ src/compile/extensions/trigger_filters.rs | 127 ++---- src/compile/filter_ir.rs | 18 +- src/compile/mod.rs | 1 + src/compile/prompt_ir.rs | 117 +++++ 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 +- src/main.rs | 24 + tests/compiler_tests.rs | 123 ++++- .../fixtures/runtime-prompt-default-agent.md | 11 + .../fixtures/runtime-prompt-inlined-agent.md | 10 + tests/gate_e2e.rs | 2 +- 34 files changed, 1914 insertions(+), 197 deletions(-) create mode 100644 scripts/ado-script/src/prompt/__tests__/frontmatter.test.ts create mode 100644 scripts/ado-script/src/prompt/__tests__/substitute.test.ts create mode 100644 scripts/ado-script/src/prompt/frontmatter.ts create mode 100644 scripts/ado-script/src/prompt/index.ts create mode 100644 scripts/ado-script/src/prompt/substitute.ts create mode 100644 scripts/ado-script/src/shared/types-prompt.gen.ts create mode 100644 scripts/ado-script/test/prompt-smoke.test.ts create mode 100644 src/compile/prompt_ir.rs 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/ado-script.yml b/.github/workflows/ado-script.yml index 3d56e4c0..0b13490f 100644 --- a/.github/workflows/ado-script.yml +++ b/.github/workflows/ado-script.yml @@ -5,6 +5,7 @@ on: paths: - "scripts/ado-script/**" - "src/compile/filter_ir.rs" + - "src/compile/prompt_ir.rs" - "src/compile/extensions/trigger_filters.rs" - "Cargo.toml" - "Cargo.lock" @@ -12,13 +13,14 @@ on: # Also run on pushes to main so any drift that slips through (e.g. a # merge that bypassed PR CI, or a force-push) is caught loudly the # moment it lands. If this fails on main, file a fix-drift issue and - # land a PR to regenerate `src/shared/types.gen.ts` and re-bundle — - # the workflow itself does not auto-PR. + # land a PR to regenerate the codegen outputs and re-bundle — the + # workflow itself does not auto-PR. push: branches: [main] paths: - "scripts/ado-script/**" - "src/compile/filter_ir.rs" + - "src/compile/prompt_ir.rs" - "src/compile/extensions/trigger_filters.rs" - "Cargo.toml" - "Cargo.lock" @@ -54,10 +56,13 @@ jobs: - name: Verify generated TypeScript is up to date run: | - if ! git diff --exit-code -- scripts/ado-script/src/shared/types.gen.ts; then + if ! git diff --exit-code -- \ + scripts/ado-script/src/shared/types.gen.ts \ + scripts/ado-script/src/shared/types-prompt.gen.ts; then echo "" - echo "::error::types.gen.ts is out of date with the Rust IR." + echo "::error::Generated TypeScript types are out of date with the Rust IR." echo "Run 'cd scripts/ado-script && npm run codegen' and commit the result." + echo "This covers both types.gen.ts (gate spec) and types-prompt.gen.ts (prompt spec)." exit 1 fi @@ -69,11 +74,11 @@ jobs: working-directory: scripts/ado-script run: npm run typecheck - - name: Build bundle (gate.js) + - name: Build bundles (gate.js, prompt.js) working-directory: scripts/ado-script run: npm run build - - name: Smoke-test bundle + - name: Smoke-test bundles working-directory: scripts/ado-script run: npx vitest run -c vitest.config.smoke.ts 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..794c786d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ Every compiled pipeline runs as three sequential jobs: │ │ ├── gitattributes.rs # .gitattributes management for compiled pipelines │ │ ├── filter_ir.rs # Filter expression IR: Fact/Predicate types, lowering, validation, codegen │ │ ├── pr_filters.rs # PR trigger filter generation (native ADO + gate steps) +│ │ ├── prompt_ir.rs # Prompt spec IR: PromptSpec/PromptSupplement, schemars schema for prompt.js │ │ ├── extensions/ # CompilerExtension trait and infrastructure extensions │ │ │ ├── mod.rs # Trait, Extension enum, collect_extensions(), re-exports │ │ │ ├── github.rs # Always-on GitHub MCP extension @@ -156,8 +157,9 @@ Every compiled pipeline runs as three sequential jobs: │ ├── update-ado-agentic-workflow.md # Guide for modifying an existing agentic pipeline │ └── 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) +│ ├── ado-script/ # TypeScript workspace for bundled gate.js, prompt.js, and future bundles +│ ├── gate.js # Bundled gate evaluator (Setup-job step; see docs/ado-script.md) +│ └── prompt.js # Bundled prompt renderer (Agent-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 +239,9 @@ 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, `prompt.js` for runtime prompt + rendering), schemars-driven type codegen, and the A2 design decision. - [`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..b6f4493d 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -3,14 +3,22 @@ `ado-script` is the umbrella name for the TypeScript workspace at [`scripts/ado-script/`](../scripts/ado-script/). It produces small, ncc-bundled Node programs that the **compiler injects into every emitted -pipeline** as runtime helpers. The first (and currently only) bundle is -`gate.js`, the trigger-filter gate evaluator. +pipeline** as runtime helpers. The current bundles are: + +- **`gate.js`** — the trigger-filter gate evaluator (Setup job). +- **`prompt.js`** — the agent prompt renderer (Agent job). Reads the + agent `.md` from the workspace at runtime, strips its front matter, + runs single-pass variable substitution, and writes the rendered + prompt for the AWF sandbox. See *What `prompt.js` does* below. > **Internal-only.** `ado-script` is not a user-facing front-matter > feature. Authors never write an `ado-script:` block in their agent > markdown. The compiler decides when an `ado-script` bundle is needed > and how to wire it. See [`docs/tools.md`](tools.md) for what *is* -> user-facing. +> user-facing. The one user-visible knob is +> [`inlined-imports: true`](front-matter.md) which opts back into the +> legacy compile-time prompt-embedding behaviour and skips +> `prompt.js`. ## What `gate.js` does @@ -141,27 +149,34 @@ scripts/ado-script/ ├── tsconfig.json # strict; noUncheckedIndexedAccess; NodeNext ├── src/ │ ├── shared/ # Reusable across all bundles -│ │ ├── types.gen.ts # AUTO-GENERATED from Rust IR — do not edit +│ │ ├── types.gen.ts # AUTO-GENERATED from GateSpec — do not edit +│ │ ├── types-prompt.gen.ts # AUTO-GENERATED from PromptSpec — do not edit │ │ ├── auth.ts # WebApi factory; SDK is dynamic-imported here │ │ ├── ado-client.ts # azure-devops-node-api wrapper + retry + timeout + pagination │ │ ├── env-facts.ts # Pipeline-variable readers + ENV_BY_FACT + BRANCH_FACTS + ref-prefix stripping │ │ ├── policy.ts # PolicyTracker state machine │ │ └── vso-logger.ts # ##vso[…] emitters with property/message escaping; complete() is idempotent -│ └── gate/ # gate.js entry point + per-concern modules -│ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit -│ ├── bypass.ts # build-reason auto-pass -│ ├── facts.ts # fact acquisition (env + REST) -│ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening -│ └── selfcancel.ts # best-effort build cancellation -├── test/ # End-to-end smoke tests -└── dist/gate/index.js # ncc bundle output (gitignored) +│ ├── gate/ # gate.js entry point + per-concern modules +│ │ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit +│ │ ├── bypass.ts # build-reason auto-pass +│ │ ├── facts.ts # fact acquisition (env + REST) +│ │ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening +│ │ └── selfcancel.ts # best-effort build cancellation +│ └── prompt/ # prompt.js entry point + per-concern modules +│ ├── index.ts # main(): decode → strip FM → assemble → substitute → write +│ ├── frontmatter.ts # stripFrontMatter (mirrors parse_markdown_detailed in Rust) +│ └── substitute.ts # single-pass substitution engine (block-the-chain attack) +├── test/ # End-to-end smoke tests for built bundles +└── dist//index.js # ncc bundle output per bundle (gitignored) ``` 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` (e.g. `gate.js`, +`prompt.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 @@ -182,24 +197,40 @@ its SHA-256 against the `checksums.txt` asset, then extract. └──────────────────────────────┘ ``` -`npm run codegen` runs both stages. The CI workflow -(`.github/workflows/ado-script.yml`) regenerates the file and runs -`git diff --exit-code` to fail on drift, on both PRs and pushes to -`main`. If you change the IR shape in Rust, run +`npm run codegen` runs both schemas: `codegen:gate` regenerates +`types.gen.ts` from `GateSpec`, and `codegen:prompt` regenerates +`types-prompt.gen.ts` from `PromptSpec`. The CI workflow +(`.github/workflows/ado-script.yml`) regenerates **both** files and +runs `git diff --exit-code` to fail on drift, on both PRs and pushes +to `main`. If you change either IR shape in Rust, run `cd scripts/ado-script && npm run codegen` and commit the regenerated -`types.gen.ts`. +type files. -The Rust subcommand that emits the schema is intentionally hidden: +The Rust subcommands that emit the schemas are intentionally hidden: ```sh -cargo run -- export-gate-schema --output schema/gate-spec.schema.json +cargo run -- export-gate-schema --output schema/gate-spec.schema.json +cargo run -- export-prompt-schema --output schema/prompt-spec.schema.json ``` ## How the gate bundle is wired into emitted pipelines `TriggerFiltersExtension` -(`src/compile/extensions/trigger_filters.rs`) injects three Setup-job -steps when any `filters:` block is active: +(`src/compile/extensions/trigger_filters.rs`) declares +`needs_scripts_bundle() == true` when any `filters:` block produces +checks. The compiler emits the shared install pair (NodeTool@0 + +checksum-verified `ado-script.zip` download) **once per job**: + +- **Setup job** — the install pair is hoisted out of the extension via + `compile/extensions/mod.rs::scripts_install_steps_if_needed`. The + trigger-filters extension then contributes only the gate step. +- **Agent job** — the runtime prompt path (when + `inlined-imports: false`, the default) emits its own copy of the + install pair via `generate_prepare_agent_prompt`. The Setup job's + download is on a different ADO agent VM, so the Agent VM must + re-download. + +The wiring for trigger filters specifically: 1. **`NodeTool@0`** — installs Node 20.x LTS, capped at `timeoutInMinutes: 5`. @@ -208,12 +239,74 @@ 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 +The IR-to-bash codegen that produces step 3 is `compile_gate_step_external` in `src/compile/filter_ir.rs`. +## What `prompt.js` does + +`prompt.js` is a single-shot Node program that runs in the **Agent +job**, before the agent is launched. It: + +1. Decodes the base64-encoded [`PromptSpec`](../src/compile/prompt_ir.rs) + from `ADO_AW_PROMPT_SPEC` and refuses to run on a mismatched + schema version. +2. Reads the agent `.md` source from the workspace at the absolute + path baked into the spec (already resolved from + `{{ trigger_repo_directory }}` at compile time, so the spec carries + a literal `$(Build.SourcesDirectory)/path/to/agent.md`). +3. Strips the YAML front-matter block (mirroring + `parse_markdown_detailed` in Rust). +4. Joins the body with any `PromptSpec.supplements` contributed by + extensions, in the same order + [`generate_prepare_steps`](../src/compile/common.rs) would have + emitted them in `inlined-imports: true` mode (Runtimes phase first, + then Tools). +5. Runs **single-pass** substitution over the joined content. The + single regex pass recognises four token shapes, with replacement + values returned verbatim and **never re-scanned**: + + | Token | Resolved via | Notes | + |--------------------------------|-------------------------------------------------|------------------------------------------------------| + | `\$(VAR)` / `\$(VAR.SUB)` | escape | Backslash stripped; `$(VAR)` stays literal. | + | `${{ parameters.NAME }}` | `ADO_AW_PARAM_` | Only parameters listed in the spec substitute. | + | `$(VAR)` / `$(VAR.SUB)` | `` (process env) | Unset vars left verbatim with a warning. | + | `$[ ... ]` | not substituted | Left verbatim with one warning per render. | + + **Single-pass is load-bearing.** It blocks the + "queue-with-malicious-parameter-value" chaining attack: if a caller + queues with `target = "$(System.AccessToken)"`, the substituted + value lands in the rendered prompt as the literal string + `$(System.AccessToken)` — not the access token itself. Same applies + in reverse: a `$(VAR)` value containing `${{ parameters.* }}` is + never re-expanded. + +6. Writes the rendered prompt to `/tmp/awf-tools/agent-prompt.md` for + the AWF sandbox. + +Like `gate.js`, `prompt.js` is a data interpreter, not a code +evaluator — there is no `eval`, no `Function`, no `vm`. A compromised +compiler cannot use the spec to execute arbitrary code on the agent +runner. + +### Opt-out: `inlined-imports: true` + +Set `inlined-imports: true` in front matter to skip `prompt.js` +entirely 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. + ## Modifying `ado-script` ### Add a new predicate @@ -255,10 +348,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 +364,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..cba98131 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,24 @@ 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 travel via [`PromptSpec.supplements`](../src/compile/prompt_ir.rs) +and are appended and substituted by `prompt.js` at pipeline runtime — they +do **not** appear as `cat >>` steps in `prepare_steps`. The substitution +applies the same `${{ parameters.* }}` / `$(VAR)` rules to supplement +content as to the body, so an extension can parameterize its supplement +the same way an author parameterizes the body. Only when +`inlined-imports: true` does the compiler wrap each supplement in a +heredoc `cat >>` step via `wrap_prompt_append`. + +**`needs_scripts_bundle()`**: Return `true` if the extension's emitted +steps invoke a bundled `ado-script.zip` helper (e.g., `gate.js`, +`prompt.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..033c4126 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,34 @@ 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 +single step that invokes the bundled +[`prompt.js`](ado-script.md) helper at runtime; it reads the agent +`.md` from the workspace, strips the front matter, appends any +extension supplements, runs single-pass variable substitution, and +writes the rendered prompt for the AWF sandbox. Body-only edits to the +`.md` therefore no longer require a `ado-aw compile` rebuild. + +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..8fd503fb 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -381,9 +381,46 @@ 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 injection).** Expands + to a three-step block in the Agent job: + 1. `NodeTool@0` installing Node 20.x (timeout-capped at 5 minutes). + 2. `bash:` step that downloads `ado-script.zip` from the release + matching the compiler version, verifies SHA-256 against the + `checksums.txt` asset, and unzips into `/tmp/ado-aw-scripts/`. + 3. `bash: node /tmp/ado-aw-scripts/prompt.js` step that carries the + base64-encoded + [`PromptSpec`](../src/compile/prompt_ir.rs) in + `ADO_AW_PROMPT_SPEC`, plus one `ADO_AW_PARAM_: + ${{ parameters. }}` line per declared parameter. The + bundle reads the agent `.md` from the workspace, strips the + front matter, appends extension supplements, runs **single-pass** + `${{ parameters.* }}` / `$(VAR)` / `\$(...)` / `$[...]` + substitution, and writes the rendered prompt to + `/tmp/awf-tools/agent-prompt.md`. The substitution pass returns + replacement values verbatim — values are never re-scanned, which + blocks the "queue-with-malicious-parameter-value" chaining + attack. +- **`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 spec's +`source_path` **before** base64-encoding the spec, so `prompt.js` sees +a fully resolved `$(Build.SourcesDirectory)/...` 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. ## {{ mcpg_config }} diff --git a/scripts/ado-script/package.json b/scripts/ado-script/package.json index 12016b78..e92f3934 100644 --- a/scripts/ado-script/package.json +++ b/scripts/ado-script/package.json @@ -7,10 +7,13 @@ "node": ">=20.0.0" }, "scripts": { - "build": "npm run codegen && npm run build:gate", + "build": "npm run codegen && npm run build:gate && npm run build:prompt", "build:gate": "ncc build src/gate/index.ts -o dist/gate -m -t", - "build:check": "ls -lh dist/gate/index.js && wc -c dist/gate/index.js", - "codegen": "mkdir -p schema && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\"", + "build:prompt": "ncc build src/prompt/index.ts -o dist/prompt -m -t", + "build:check": "ls -lh dist/gate/index.js dist/prompt/index.js && wc -c dist/gate/index.js dist/prompt/index.js", + "codegen": "npm run codegen:gate && npm run codegen:prompt", + "codegen:gate": "mkdir -p schema && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\"", + "codegen:prompt": "mkdir -p schema && cargo run --quiet --manifest-path ../../Cargo.toml -- export-prompt-schema --output schema/prompt-spec.schema.json && npx json2ts schema/prompt-spec.schema.json -o src/shared/types-prompt.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-prompt-schema. Do not edit; run npm run codegen.\"", "test": "vitest run", "test:smoke": "npm run build && vitest run -c vitest.config.smoke.ts", "lint": "echo TODO", 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/prompt/__tests__/frontmatter.test.ts b/scripts/ado-script/src/prompt/__tests__/frontmatter.test.ts new file mode 100644 index 00000000..3d051868 --- /dev/null +++ b/scripts/ado-script/src/prompt/__tests__/frontmatter.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { FrontMatterError, stripFrontMatter } from "../frontmatter.js"; + +describe("stripFrontMatter", () => { + it("returns body unchanged when no front matter", () => { + const src = "# Hello\n\nNo front matter here."; + expect(stripFrontMatter(src)).toBe(src); + }); + + it("strips a simple front-matter block, preserving body whitespace", () => { + // The close delimiter line (`---\n`) is consumed in full; any + // blank lines between the close and the body are preserved as-is + // (matches Rust's `body_raw` semantics — the runtime caller + // applies its own `.trim()` before joining with supplements). + const src = "---\nname: x\ndescription: y\n---\n\nBody text\n"; + expect(stripFrontMatter(src)).toBe("\nBody text\n"); + }); + + it("preserves leading whitespace in the body", () => { + // Regression for the same kind of bug that fooled the inlined + // branch's `trim_start_matches(' ')`: stripping the front matter + // must NOT eat author-supplied leading whitespace in the body. + const src = "---\nname: x\ndescription: y\n---\n indented opening\nplain"; + expect(stripFrontMatter(src)).toBe(" indented opening\nplain"); + }); + + it("supports CRLF line endings", () => { + const src = "---\r\nname: x\r\n---\r\n\r\nBody\r\n"; + expect(stripFrontMatter(src)).toBe("\r\nBody\r\n"); + }); + + it("throws when front matter is opened but never closed", () => { + const src = "---\nname: x\nno closing delim here"; + expect(() => stripFrontMatter(src)).toThrow(FrontMatterError); + }); + + it("does not treat mid-body --- as a close", () => { + // The body itself contains `---` (e.g. a horizontal rule). That + // first `---` should still be the close delimiter, because Rust + // semantics close on the FIRST `---` line after the open. Any + // blank line between the close and the first body content is + // preserved as a leading newline. + const src = "---\nname: x\n---\n\nIntro\n\n---\n\nRest\n"; + expect(stripFrontMatter(src)).toBe("\nIntro\n\n---\n\nRest\n"); + }); + + it("returns empty string when body is empty", () => { + const src = "---\nname: x\n---\n"; + expect(stripFrontMatter(src)).toBe(""); + }); + + it("handles front matter immediately followed by content", () => { + // No blank line between `---` and the first content line. + const src = "---\nname: x\n---\nFirst line"; + expect(stripFrontMatter(src)).toBe("First line"); + }); +}); diff --git a/scripts/ado-script/src/prompt/__tests__/substitute.test.ts b/scripts/ado-script/src/prompt/__tests__/substitute.test.ts new file mode 100644 index 00000000..da08890c --- /dev/null +++ b/scripts/ado-script/src/prompt/__tests__/substitute.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { substitute, type WarnFn } from "../substitute.js"; + +const ORIGINAL_ENV = { ...process.env }; + +beforeEach(() => { + // Clear any ADO_*/ADO_AW_PARAM_*/PIPELINE_* env vars between tests so + // accidental host env doesn't leak into assertions. + for (const k of Object.keys(process.env)) { + if ( + k.startsWith("ADO_AW_PARAM_") || + k.startsWith("ADO_") || + k.startsWith("BUILD_") || + k.startsWith("SYSTEM_") || + k.startsWith("MYVAR") + ) { + delete process.env[k]; + } + } +}); + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +function noopWarn(): WarnFn { + return vi.fn(); +} + +function collectWarn(): { warn: WarnFn; warnings: string[] } { + const warnings: string[] = []; + return { warn: (m: string) => warnings.push(m), warnings }; +} + +describe("substitute", () => { + it("leaves a string with no tokens unchanged", () => { + expect(substitute("just plain text", [], noopWarn())).toBe( + "just plain text", + ); + }); + + it("substitutes a declared parameter from env", () => { + process.env["ADO_AW_PARAM_TARGET"] = "main"; + const out = substitute( + "Build ${{ parameters.target }} now.", + ["target"], + noopWarn(), + ); + expect(out).toBe("Build main now."); + }); + + it("handles hyphenated parameter names (hyphen → underscore in env)", () => { + process.env["ADO_AW_PARAM_TARGET_BRANCH"] = "release/1.0"; + const out = substitute( + "Branch ${{ parameters.target-branch }}", + ["target-branch"], + noopWarn(), + ); + expect(out).toBe("Branch release/1.0"); + }); + + it("leaves an undeclared parameter verbatim and warns once", () => { + const { warn, warnings } = collectWarn(); + const out = substitute( + "Hi ${{ parameters.nope }} and ${{ parameters.nope }} again.", + ["target"], + warn, + ); + expect(out).toBe( + "Hi ${{ parameters.nope }} and ${{ parameters.nope }} again.", + ); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/Unknown parameter 'nope'/); + }); + + it("leaves a declared-but-unset parameter verbatim and warns once", () => { + const { warn, warnings } = collectWarn(); + const out = substitute("Run ${{ parameters.target }}.", ["target"], warn); + expect(out).toBe("Run ${{ parameters.target }}."); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/env var 'ADO_AW_PARAM_TARGET' is unset/); + }); + + it("substitutes $(VAR) from env", () => { + process.env["BUILD_ID"] = "42"; + expect(substitute("Run $(Build.Id)", [], noopWarn())).toBe("Run 42"); + }); + + it("substitutes $(VAR) with simple uppercase mapping", () => { + process.env["MYVAR"] = "hello"; + expect(substitute("$(myvar)", [], noopWarn())).toBe("hello"); + }); + + it("leaves $(VAR) verbatim and warns when env is unset", () => { + const { warn, warnings } = collectWarn(); + const out = substitute("Secret $(MissingSecret)", [], warn); + expect(out).toBe("Secret $(MissingSecret)"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/MISSINGSECRET/); + }); + + it("does NOT re-expand $(...) injected via a parameter value", () => { + // SECURITY: the chaining attack flagged by PR #395's bot review. + // A user queues the pipeline with `target = "$(System.AccessToken)"`. + // We must NOT then expand that `$(System.AccessToken)` against the + // env in a second pass. + process.env["ADO_AW_PARAM_TARGET"] = "$(System.AccessToken)"; + process.env["SYSTEM_ACCESSTOKEN"] = "SECRET"; + const out = substitute( + "target=${{ parameters.target }}", + ["target"], + noopWarn(), + ); + expect(out).toBe("target=$(System.AccessToken)"); + expect(out).not.toContain("SECRET"); + }); + + it("does NOT re-expand ${{ parameters.* }} injected via $(VAR)", () => { + // The mirror attack: an env var value containing a parameter token. + // Substitution must stay single-pass in both directions. + process.env["INJECTED"] = "${{ parameters.secret }}"; + process.env["ADO_AW_PARAM_SECRET"] = "leaked"; + const out = substitute("v=$(INJECTED)", ["secret"], noopWarn()); + expect(out).toBe("v=${{ parameters.secret }}"); + expect(out).not.toContain("leaked"); + }); + + it("strips the backslash from \\$(VAR) and leaves $(VAR) literal", () => { + process.env["X"] = "should not appear"; + const out = substitute("literal: \\$(X)", [], noopWarn()); + expect(out).toBe("literal: $(X)"); + }); + + it("leaves $[...] expressions verbatim and warns once", () => { + const { warn, warnings } = collectWarn(); + const out = substitute( + "ver = $[counter('x',0)] and again $[counter('x',0)]", + [], + warn, + ); + expect(out).toBe("ver = $[counter('x',0)] and again $[counter('x',0)]"); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/Runtime expression/); + }); + + it("substitutes multiple distinct tokens in a single pass", () => { + process.env["ADO_AW_PARAM_TARGET"] = "main"; + process.env["BUILD_ID"] = "99"; + const out = substitute( + "build $(Build.Id) of ${{ parameters.target }}", + ["target"], + noopWarn(), + ); + expect(out).toBe("build 99 of main"); + }); + + it("warns at most once per distinct unresolved token", () => { + const { warn, warnings } = collectWarn(); + substitute("$(X) $(Y) $(X) $(Y) $(X)", [], warn); + // Two distinct env vars → two warnings, regardless of how many + // times each appears. + expect(warnings).toHaveLength(2); + }); + + it("preserves surrounding whitespace and punctuation", () => { + process.env["ADO_AW_PARAM_NAME"] = "Sue"; + const out = substitute( + "Hi, ${{ parameters.name }}!", + ["name"], + noopWarn(), + ); + expect(out).toBe("Hi, Sue!"); + }); +}); diff --git a/scripts/ado-script/src/prompt/frontmatter.ts b/scripts/ado-script/src/prompt/frontmatter.ts new file mode 100644 index 00000000..281e366d --- /dev/null +++ b/scripts/ado-script/src/prompt/frontmatter.ts @@ -0,0 +1,53 @@ +/** + * YAML front-matter stripper for prompt source files. + * + * The agent `.md` is structured as: + * + * ``` + * --- + * + * --- + * + * + * ``` + * + * `stripFrontMatter` returns the markdown body, leaving line breaks + * inside the body untouched so any author-supplied indentation / + * fenced-code blocks survive intact. + * + * Mirrors `extract_front_matter` in `src/compile/common.rs` (Rust) — + * keep semantics in lockstep. + */ +export class FrontMatterError extends Error { + constructor(message: string) { + super(message); + this.name = "FrontMatterError"; + } +} + +const FRONT_MATTER_OPEN = /^---\r?\n/; + +export function stripFrontMatter(source: string): string { + // No front-matter delimiter at the very top → treat the entire source + // as body. (Matches Rust's behaviour of accepting plain markdown.) + if (!FRONT_MATTER_OPEN.test(source)) { + return source; + } + + // Strip the opening `---` line. + const afterOpen = source.replace(FRONT_MATTER_OPEN, ""); + + // Find the next `---` line. It must start at column 0 of a line + // (anchored with `\n` before) — a `---` mid-content is not a close + // delimiter. + const closeMatch = afterOpen.match(/(^|\r?\n)---(\r?\n|$)/); + if (!closeMatch) { + throw new FrontMatterError( + "Front matter is open (`---`) but no closing `---` was found.", + ); + } + // Consume the full close-delimiter match including its trailing + // newline (group 2). Anything after that is the body. + const fullEnd = closeMatch.index! + closeMatch[0].length; + return afterOpen.slice(fullEnd); +} diff --git a/scripts/ado-script/src/prompt/index.ts b/scripts/ado-script/src/prompt/index.ts new file mode 100644 index 00000000..86597978 --- /dev/null +++ b/scripts/ado-script/src/prompt/index.ts @@ -0,0 +1,107 @@ +/** + * Prompt renderer entry point. + * + * Reads a base64-encoded `PromptSpec` from `ADO_AW_PROMPT_SPEC` env, + * loads the source markdown from the workspace, strips its YAML front + * matter, runs single-pass variable substitution, appends extension + * supplements (themselves substituted), and writes the result to the + * configured output path. On any unrecoverable error, logs via VSO and + * exits non-zero. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import type { PromptSpec } from "../shared/types-prompt.gen.js"; +import { stripFrontMatter, FrontMatterError } from "./frontmatter.js"; +import { substitute } from "./substitute.js"; +import { complete, logError, logWarning } from "../shared/vso-logger.js"; + +/** + * The `version` value this build of `prompt.js` understands. Must be + * kept in lockstep with `PROMPT_SPEC_VERSION` in + * `src/compile/prompt_ir.rs`. + */ +const SUPPORTED_VERSION = 1; + +function decodeSpec(raw: string): PromptSpec { + const json = Buffer.from(raw, "base64").toString("utf8"); + return JSON.parse(json) as PromptSpec; +} + +export async function main(): Promise { + const raw = process.env["ADO_AW_PROMPT_SPEC"]; + if (!raw) { + logError("ADO_AW_PROMPT_SPEC env var missing"); + complete("Failed"); + process.exit(1); + } + + let spec: PromptSpec; + try { + spec = decodeSpec(raw); + } catch (e) { + logError(`Failed to decode ADO_AW_PROMPT_SPEC: ${(e as Error).message}`); + complete("Failed"); + process.exit(1); + } + + if (spec.version !== SUPPORTED_VERSION) { + logError( + `Unsupported PromptSpec version ${spec.version}; this prompt.js supports version ${SUPPORTED_VERSION}. ` + + "Recompile your pipeline with a matching ado-aw release.", + ); + complete("Failed"); + process.exit(1); + } + + if (!fs.existsSync(spec.source_path)) { + logError(`Source markdown not found: ${spec.source_path}`); + complete("Failed"); + process.exit(1); + } + + const source = fs.readFileSync(spec.source_path, "utf8"); + let body: string; + try { + body = stripFrontMatter(source); + } catch (e) { + const msg = + e instanceof FrontMatterError ? e.message : (e as Error).message; + logError(`Failed to strip front matter from ${spec.source_path}: ${msg}`); + complete("Failed"); + process.exit(1); + } + + // Assemble body + supplements first, *then* substitute in one pass. + // Each section is individually trimmed so the joined text doesn't + // accumulate blank lines. + const parts: string[] = [body.trim()]; + for (const supp of spec.supplements) { + parts.push(supp.content.trim()); + } + const assembled = parts.filter((s) => s.length > 0).join("\n\n"); + + const rendered = substitute(assembled, spec.parameters, (msg) => + logWarning(msg), + ); + + if (rendered.trim().length === 0) { + logError( + `Rendered prompt is empty (source: ${spec.source_path}). ` + + "Front-matter-only files are not valid agents.", + ); + complete("Failed"); + process.exit(1); + } + + fs.mkdirSync(path.dirname(spec.output_path), { recursive: true }); + fs.writeFileSync(spec.output_path, rendered); + + complete("Succeeded", `prompt rendered: ${spec.output_path}`); +} + +main().catch((e) => { + logError(`prompt renderer crashed: ${(e as Error).message}`); + complete("Failed"); + process.exit(1); +}); diff --git a/scripts/ado-script/src/prompt/substitute.ts b/scripts/ado-script/src/prompt/substitute.ts new file mode 100644 index 00000000..3caa48a3 --- /dev/null +++ b/scripts/ado-script/src/prompt/substitute.ts @@ -0,0 +1,127 @@ +/** + * Single-pass substitution engine for `prompt.js`. + * + * Recognised tokens (priority order, matched left-to-right): + * + * | Token | Resolved via | Notes | + * |--------------------------------|----------------------------------------------------|------------------------------------------------| + * | `\$(VAR)` / `\$(VAR.SUB)` | escape | Backslash stripped; `$(...)` left literal. | + * | `${{ parameters.NAME }}` | `ADO_AW_PARAM_` | Only declared parameters substitute. | + * | `$(VAR)` / `$(VAR.SUB)` | `` (process env) | Unset vars left verbatim with a warning. | + * | `$[ ... ]` | not substituted | Left verbatim with one warning per render. | + * + * **Single-pass is load-bearing**: the function walks the input string + * exactly once with a global regex. Replacement values are returned + * verbatim and are **never re-scanned**. This blocks the + * "queue-with-malicious-parameter-value" chaining attack where a + * caller-supplied parameter value contains `$(...)` and would otherwise + * be expanded by a subsequent pass. + * + * Each "unknown" diagnostic (unset env var, unknown parameter, + * `$[...]` expression) is reported once per render via the `warn` + * callback. The caller is expected to forward those to VSO + * `##vso[task.logissue]`. + */ + +// Alternation in priority order: +// 1. Escape: \$(...) → strip the backslash, leave $(...) literal +// 2. Parameter: ${{ parameters.NAME }} +// 3. Variable: $(NAME) or $(NAME.SUB) +// 4. Runtime: $[ ... ] → not substituted; warn once +const TOKEN_RE = + /\\\$\((?[^()]*)\)|\$\{\{\s*parameters\.(?[A-Za-z_][A-Za-z0-9_-]*)\s*\}\}|\$\((?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\)|\$\[(?[^\]]*)\]/g; + +export type WarnFn = (msg: string) => void; + +/** + * Run the substitution pipeline against `source`. + * + * @param source Rendered prompt content (body + supplements joined). + * @param parameters List of parameter names declared in `PromptSpec`. + * Names not in this list are not substituted. + * @param warn Callback for one-line warnings about unresolved + * tokens. Called at most once per distinct token. + */ +export function substitute( + source: string, + parameters: readonly string[], + warn: WarnFn, +): string { + const allowedParams = new Set(parameters); + const warned = new Set(); + const warnOnce = (key: string, msg: string): void => { + if (warned.has(key)) return; + warned.add(key); + warn(msg); + }; + + return source.replace(TOKEN_RE, (match, ...args: unknown[]): string => { + // String.prototype.replace with a function callback signature is + // `(match, p1, p2, ..., offset, string, groups)`. We grab the last + // argument as the named-groups object. + const groups = args[args.length - 1] as + | Record + | undefined; + if (!groups) return match; + + // ─── Escape `\$(...)` → `$(...)` literal ──────────────────────── + if (groups["escVar"] !== undefined) { + return `$(${groups["escVar"]})`; + } + + // ─── `${{ parameters.NAME }}` ─────────────────────────────────── + if (groups["param"] !== undefined) { + const name = groups["param"]; + if (!allowedParams.has(name)) { + warnOnce( + `param:${name}`, + `Unknown parameter '${name}'; left as-is. Declare it in the agent front matter to enable substitution.`, + ); + return match; + } + const envName = `ADO_AW_PARAM_${name.toUpperCase().replace(/-/g, "_")}`; + const value = process.env[envName]; + if (value === undefined) { + warnOnce( + `paramEnv:${envName}`, + `Parameter '${name}' is declared but env var '${envName}' is unset; left as-is.`, + ); + return match; + } + // Value is returned verbatim — the outer .replace() does NOT + // recurse into our return value, so any `$(...)` in `value` + // stays literal. + return value; + } + + // ─── `$(VAR)` / `$(VAR.SUB)` ────────────────────────────────── + if (groups["var"] !== undefined) { + const ref = groups["var"]; + const envName = ref.toUpperCase().replace(/\./g, "_"); + const value = process.env[envName]; + if (value === undefined) { + warnOnce( + `var:${envName}`, + `ADO variable '$(${ref})' is unset (env var '${envName}'); left as-is. Secrets are not auto-exposed — set 'env: { ${envName}: $(${ref}) }' on the step to expose.`, + ); + return match; + } + return value; + } + + // ─── `$[ ... ]` runtime expression — not supported ──────────── + if (groups["expr"] !== undefined) { + const expr = groups["expr"]; + warnOnce( + `expr:$[${expr}]`, + `Runtime expression '$[${expr}]' is not substituted by prompt.js; left as-is.`, + ); + return match; + } + + return match; + }); +} + +// Re-export the regex so tests can introspect / pin its behaviour. +export const _TOKEN_RE_FOR_TESTS = TOKEN_RE; diff --git a/scripts/ado-script/src/shared/types-prompt.gen.ts b/scripts/ado-script/src/shared/types-prompt.gen.ts new file mode 100644 index 00000000..07ac4b8f --- /dev/null +++ b/scripts/ado-script/src/shared/types-prompt.gen.ts @@ -0,0 +1,49 @@ +// AUTO-GENERATED from Rust IR via cargo run -- export-prompt-schema. Do not edit; run npm run codegen. + +/** + * Top-level spec consumed by `prompt.js` at pipeline runtime. + */ +export interface PromptSpec { + /** + * Absolute path where the rendered prompt should be written. + */ + output_path: string; + /** + * Declared parameter names available for `${{ parameters.NAME }}` + * substitution. Names not in this list are left verbatim by + * `prompt.js` with a runtime warning. + */ + parameters: string[]; + /** + * Absolute path to the source `.md` file in the workspace. + * The compiler resolves `{{ trigger_repo_directory }}` before + * encoding so `prompt.js` sees a fully resolved path. + */ + source_path: string; + /** + * Extension prompt supplements, in render order + * (Runtimes phase first, then Tools phase, stable within each phase). + */ + supplements: PromptSupplement[]; + /** + * Schema version; refused on mismatch. + */ + version: number; + [k: string]: unknown; +} +/** + * One block of additional prompt content contributed by an extension. + */ +export interface PromptSupplement { + /** + * Markdown to append. May contain `${{ parameters.* }}` or `$(VAR)` + * references; substituted by `prompt.js` using the same single-pass + * rules as the body. + */ + content: string; + /** + * Extension display name (used for VSO logging only — not rendered). + */ + name: string; + [k: string]: unknown; +} 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/scripts/ado-script/test/prompt-smoke.test.ts b/scripts/ado-script/test/prompt-smoke.test.ts new file mode 100644 index 00000000..e9e22658 --- /dev/null +++ b/scripts/ado-script/test/prompt-smoke.test.ts @@ -0,0 +1,142 @@ +/** + * End-to-end smoke test of the bundled prompt.js. + * + * Spawns `node dist/prompt/index.js` as a subprocess with a hand-rolled + * `PromptSpec` (base64-encoded into `ADO_AW_PROMPT_SPEC`) and a known + * source `.md` file in a temp directory. Verifies that the rendered + * output is what the contract promises: front matter stripped, + * supplements appended, and `${{ parameters.* }}` / `$(VAR)` patterns + * substituted. + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const bundlePath = resolve(__dirname, "../dist/prompt/index.js"); + +interface Spec { + version: number; + source_path: string; + output_path: string; + supplements: { name: string; content: string }[]; + parameters: string[]; +} + +function encodeSpec(spec: Spec): string { + return Buffer.from(JSON.stringify(spec)).toString("base64"); +} + +function runPrompt( + spec: Spec, + extraEnv: Record = {}, +): { + stdout: string; + stderr: string; + status: number | null; +} { + const result = spawnSync(process.execPath, [bundlePath], { + env: { + PATH: process.env["PATH"] ?? "", + ADO_AW_PROMPT_SPEC: encodeSpec(spec), + ...extraEnv, + }, + encoding: "utf8", + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + status: result.status, + }; +} + +let workdir: string; + +beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "prompt-smoke-")); +}); + +afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); +}); + +describe("prompt.js smoke", () => { + it("renders front-matter-stripped body with supplement and parameter", () => { + const source = join(workdir, "agent.md"); + const output = join(workdir, "out.md"); + writeFileSync( + source, + "---\nname: x\ndescription: y\n---\n# Hello ${{ parameters.target }}\n", + ); + const spec: Spec = { + version: 1, + source_path: source, + output_path: output, + supplements: [{ name: "Lean 4", content: "Use lake build." }], + parameters: ["target"], + }; + const { status, stderr } = runPrompt(spec, { ADO_AW_PARAM_TARGET: "main" }); + expect(status, `prompt failed: ${stderr}`).toBe(0); + const rendered = readFileSync(output, "utf8"); + expect(rendered).toContain("# Hello main"); + expect(rendered).toContain("Use lake build."); + }); + + it("fails when the source .md does not exist", () => { + const spec: Spec = { + version: 1, + source_path: join(workdir, "missing.md"), + output_path: join(workdir, "out.md"), + supplements: [], + parameters: [], + }; + const { status, stdout } = runPrompt(spec); + expect(status).not.toBe(0); + expect(stdout).toContain("Source markdown not found"); + }); + + it("fails when PromptSpec version is unknown", () => { + const source = join(workdir, "agent.md"); + writeFileSync(source, "---\nname: x\n---\nbody\n"); + const spec: Spec = { + version: 9999, + source_path: source, + output_path: join(workdir, "out.md"), + supplements: [], + parameters: [], + }; + const { status, stdout } = runPrompt(spec); + expect(status).not.toBe(0); + expect(stdout).toContain("Unsupported PromptSpec version"); + }); + + it("does NOT re-expand $(...) injected via a parameter value (single-pass)", () => { + // Mirror of the unit-test attack at the smoke layer: even with + // every env-var supplier in place, the chained value must stay + // literal in the rendered output. + const source = join(workdir, "agent.md"); + const output = join(workdir, "out.md"); + writeFileSync( + source, + "---\nname: x\n---\nTarget: ${{ parameters.target }}\n", + ); + const spec: Spec = { + version: 1, + source_path: source, + output_path: output, + supplements: [], + parameters: ["target"], + }; + const { status, stderr } = runPrompt(spec, { + ADO_AW_PARAM_TARGET: "$(System.AccessToken)", + SYSTEM_ACCESSTOKEN: "SECRET", + }); + expect(status, `prompt failed: ${stderr}`).toBe(0); + const rendered = readFileSync(output, "utf8"); + expect(rendered).toContain("Target: $(System.AccessToken)"); + expect(rendered).not.toContain("SECRET"); + }); +}); diff --git a/src/compile/common.rs b/src/compile/common.rs index 36b923a8..73e62d02 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1,15 +1,22 @@ //! Common helper functions shared across all compile targets. use anyhow::{Context, Result}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; 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, + bundle_path, +}; +use super::prompt_ir::{PROMPT_SPEC_VERSION, PromptSpec, PromptSupplement}; +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 +2088,14 @@ 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 (gate.js Setup step, future prompt.js Agent step, …) + // 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 +2189,18 @@ 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 delivered to `prompt.js` via the [`PromptSpec`] +/// env contract 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 +2209,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 +2223,163 @@ 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-prompt branch to populate +/// [`PromptSpec::supplements`]. +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 +} + +/// 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"; + +/// Emit the YAML step(s) that prepare `/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` (default), emits a single bash +/// step that invokes the bundled `prompt.js` helper with the +/// [`PromptSpec`] passed via the `ADO_AW_PROMPT_SPEC` env var +/// (base64-encoded JSON). The shared `NodeTool@0` + scripts-download +/// pair is emitted once by the compiler in `generate_setup_job`, so +/// this function does not duplicate them. +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 — `prompt.js` reads the source from the workspace. + // Reject any source path that won't be resolvable at pipeline runtime + // ($(Build.SourcesDirectory) is the canonical workspace root). The + // caller (compile_shared) resolves `{{ trigger_repo_directory }}` + // before we get here, so a workspace-relative path always starts + // with that prefix; the only failure mode is the filename-only + // fallback that `generate_source_path` emits for absolute inputs + // outside the trigger repo. + if !source_path.starts_with("$(Build.SourcesDirectory)") { + anyhow::bail!( + "Cannot determine workspace-relative path for the agent .md \ + ({source_path}). `prompt.js` cannot read the source at \ + pipeline 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." + ); + } + + let spec = PromptSpec { + version: PROMPT_SPEC_VERSION, + source_path: source_path.to_string(), + output_path: AGENT_PROMPT_PATH.to_string(), + supplements, + parameters: parameters.iter().map(|p| p.name.clone()).collect(), + }; + let json = serde_json::to_string(&spec).context("serializing PromptSpec to JSON")?; + let b64 = BASE64.encode(json.as_bytes()); + + // Each declared pipeline parameter gets its own env var. We resolve + // `${{ parameters. }}` at compile time so ADO performs the + // substitution before `prompt.js` ever runs. The env var name is + // uppercased and `-` is replaced with `_` to match + // `substitute.ts::lookupParam`. + let mut env_lines = Vec::new(); + env_lines.push(format!(" ADO_AW_PROMPT_SPEC: \"{b64}\"")); + 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 = env_lines.join("\n"); + + let prompt_js = bundle_path("prompt"); + let node_step = super::extensions::node_tool_step( + "Install Node.js 20.x for prompt renderer", + ); + let download_step = super::extensions::scripts_download_step(); + + // The Agent job runs on a different ADO agent than the Setup job, so + // even when the gate has already downloaded `ado-script.zip` in the + // Setup job, the Agent VM must download it again. We therefore emit + // the full NodeTool@0 + download + invoke triple here, regardless of + // whether the gate path also fetches the bundle. + Ok(format!( + r#"{node_step} + +{download_step} + +- bash: | + set -euo pipefail + mkdir -p "$(dirname "{output}")" + node {prompt_js} + echo "Agent prompt:" + cat "{output}" + displayName: "Render agent prompt" + env: +{env_block}"#, + output = AGENT_PROMPT_PATH, + )) +} + /// Generate finalize steps (inline) pub fn generate_finalize_steps(finalize_steps: &[serde_yaml::Value]) -> String { if finalize_steps.is_empty() { @@ -3004,7 +3187,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 +3317,22 @@ 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 build the `PromptSpec`: the spec is base64-encoded into a single + // env value, so the outer template-substitution pass cannot reach + // inside the base64 blob to expand markers later. + 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 +3371,12 @@ 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 the + // NodeTool@0 + scripts download + `node prompt.js` triple + // (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 +6142,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 +6157,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 +6171,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 +6188,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 +6204,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 +6221,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 +6234,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 +6247,208 @@ 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 `PromptSpec.supplements` (rendered by `prompt.js`), + // NOT via `wrap_prompt_append` heredoc steps. The prepare-steps + // string 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_emits_prompt_js_invocation() { + let yaml = generate_prepare_agent_prompt( + false, + "Hello world", + "$(Build.SourcesDirectory)/agents/x.md", + vec![], + &[], + ) + .unwrap(); + assert!( + yaml.contains("node /tmp/ado-aw-scripts/prompt.js"), + "runtime branch must invoke prompt.js: {yaml}" + ); + assert!( + yaml.contains("ADO_AW_PROMPT_SPEC:"), + "runtime branch must emit ADO_AW_PROMPT_SPEC env: {yaml}" + ); + assert!( + yaml.contains("NodeTool@0"), + "runtime branch must install Node in the Agent job: {yaml}" + ); + assert!( + yaml.contains("ado-script.zip"), + "runtime branch must download the scripts bundle: {yaml}" + ); + // Body must NOT appear verbatim in the runtime branch output. + assert!( + !yaml.contains("Hello world"), + "runtime branch must not embed body verbatim: {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("prompt.js"), + "inlined branch must not invoke prompt.js: {yaml}" + ); + assert!( + !yaml.contains("ADO_AW_PROMPT_SPEC"), + "inlined branch must not emit the runtime env var: {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}" + ); + // The author-supplied 4 spaces should still appear after the + // 4-space prefix injection + strip. Each line gets the + // alignment indent, then the first 4 chars of the joined string + // (the alignment) are stripped, leaving the author's 4 spaces + // intact. + 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(); + // Hyphens become underscores and the name is upper-cased. + 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}" + ); + } + + // ─── 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..195638a4 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,85 @@ 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"`). +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`. +/// +/// Both consumers (gate Setup job and prompt Agent job) call this and +/// prepend the result to their own steps. Each consuming job emits the +/// install pair exactly once. +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/mod.rs b/src/compile/mod.rs index 59519e52..efeedbd7 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod codemods; mod job; mod onees; pub(crate) mod pr_filters; +pub(crate) mod prompt_ir; mod stage; mod standalone; pub mod types; diff --git a/src/compile/prompt_ir.rs b/src/compile/prompt_ir.rs new file mode 100644 index 00000000..9a6dbb78 --- /dev/null +++ b/src/compile/prompt_ir.rs @@ -0,0 +1,117 @@ +//! Serializable specification for the runtime prompt renderer +//! (`prompt.js`). Mirrors the design of [`super::filter_ir::GateSpec`]. +//! +//! The compiler builds a [`PromptSpec`] at compile time, JSON-serializes it, +//! base64-encodes the result, and emits it as `ADO_AW_PROMPT_SPEC` env on the +//! `prompt.js` step. At pipeline runtime, `prompt.js` decodes the spec, +//! validates the version, reads the source markdown from the workspace, +//! strips its front matter, applies single-pass variable substitution, +//! appends supplements, and writes the rendered prompt to `output_path`. +//! +//! The JSON Schema generated by [`generate_prompt_spec_schema`] is the +//! contract surface between Rust and the TypeScript runtime bundle: +//! `npm run codegen` in `scripts/ado-script/` converts it to +//! `src/shared/types-prompt.gen.ts` via `json-schema-to-typescript`. + +use schemars::JsonSchema; +use serde::Serialize; + +/// Pinned schema version. Bump only on breaking changes to [`PromptSpec`]. +/// `prompt.js` refuses to run on an unknown version. +pub const PROMPT_SPEC_VERSION: u32 = 1; + +/// Top-level spec consumed by `prompt.js` at pipeline runtime. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PromptSpec { + /// Schema version; refused on mismatch. + pub version: u32, + /// Absolute path to the source `.md` file in the workspace. + /// The compiler resolves `{{ trigger_repo_directory }}` before + /// encoding so `prompt.js` sees a fully resolved path. + pub source_path: String, + /// Absolute path where the rendered prompt should be written. + pub output_path: String, + /// Extension prompt supplements, in render order + /// (Runtimes phase first, then Tools phase, stable within each phase). + pub supplements: Vec, + /// Declared parameter names available for `${{ parameters.NAME }}` + /// substitution. Names not in this list are left verbatim by + /// `prompt.js` with a runtime warning. + pub parameters: Vec, +} + +/// One block of additional prompt content contributed by an extension. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PromptSupplement { + /// Extension display name (used for VSO logging only — not rendered). + pub name: String, + /// Markdown to append. May contain `${{ parameters.* }}` or `$(VAR)` + /// references; substituted by `prompt.js` using the same single-pass + /// rules as the body. + pub content: String, +} + +/// Generate the JSON Schema for [`PromptSpec`] (consumed by the +/// TS workspace's codegen step). +pub fn generate_prompt_spec_schema() -> String { + let schema = schemars::schema_for!(PromptSpec); + serde_json::to_string_pretty(&schema).expect("PromptSpec schema must serialize") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_is_valid_json() { + let s = generate_prompt_spec_schema(); + let _: serde_json::Value = + serde_json::from_str(&s).expect("schema must be valid JSON"); + } + + #[test] + fn version_is_pinned() { + assert_eq!(PROMPT_SPEC_VERSION, 1); + } + + #[test] + fn schema_contains_expected_top_level_fields() { + let s = generate_prompt_spec_schema(); + assert!(s.contains("\"version\"")); + assert!(s.contains("\"source_path\"")); + assert!(s.contains("\"output_path\"")); + assert!(s.contains("\"supplements\"")); + assert!(s.contains("\"parameters\"")); + } + + #[test] + fn spec_serializes_to_expected_json_keys() { + let spec = PromptSpec { + version: PROMPT_SPEC_VERSION, + source_path: "/tmp/x.md".into(), + output_path: "/tmp/y.md".into(), + supplements: vec![PromptSupplement { + name: "Demo".into(), + content: "demo".into(), + }], + parameters: vec!["foo".into()], + }; + let json = serde_json::to_string(&spec).unwrap(); + assert!(json.contains("\"version\":1")); + assert!(json.contains("\"source_path\":\"/tmp/x.md\"")); + assert!(json.contains("\"output_path\":\"/tmp/y.md\"")); + assert!(json.contains("\"supplements\"")); + assert!(json.contains("\"parameters\":[\"foo\"]")); + } + + #[test] + fn supplement_serializes_with_camel_or_snake_keys() { + let supp = PromptSupplement { + name: "Lean 4".into(), + content: "Use lake build for verification.".into(), + }; + let json = serde_json::to_string(&supp).unwrap(); + assert!(json.contains("\"name\":\"Lean 4\"")); + assert!(json.contains("\"content\":\"Use lake build for verification.\"")); + } +} 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/src/main.rs b/src/main.rs index faacb3db..8a628569 100644 --- a/src/main.rs +++ b/src/main.rs @@ -392,6 +392,14 @@ enum Commands { #[arg(short, long)] output: Option, }, + /// Export the prompt spec JSON Schema (build-time tool for the + /// scripts/ado-script TypeScript workspace). + #[command(hide = true)] + ExportPromptSchema { + /// Output path; if omitted, prints to stdout. + #[arg(short, long)] + output: Option, + }, } #[derive(Parser, Debug)] @@ -757,6 +765,7 @@ async fn main() -> Result<()> { Some(Commands::Status { .. }) => "status", Some(Commands::Run { .. }) => "run", Some(Commands::ExportGateSchema { .. }) => "export-gate-schema", + Some(Commands::ExportPromptSchema { .. }) => "export-prompt-schema", None => "ado-aw", }; @@ -1081,6 +1090,21 @@ async fn main() -> Result<()> { None => print!("{}", schema), } } + Commands::ExportPromptSchema { output } => { + let schema = compile::prompt_ir::generate_prompt_spec_schema(); + match output { + Some(path) => { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, &schema)?; + } + None => print!("{}", schema), + } + } } Ok(()) } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 85de086e..9dad0819 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -4005,7 +4005,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!( @@ -4043,7 +4043,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"); @@ -4282,3 +4282,122 @@ fn test_example_dogfood_failure_reporter_structure() { "Example should target githubnext/ado-aw" ); } + +// ─── Runtime prompt injection (`prompt.js` bundle) ────────────────────────── + +/// Default mode: body is NOT embedded in the compiled YAML, prompt.js is +/// invoked at runtime, and ADO_AW_PROMPT_SPEC is delivered via env. +#[test] +fn test_runtime_prompt_default_emits_prompt_js_invocation() { + let compiled = compile_fixture("runtime-prompt-default-agent.md"); + + assert!( + compiled.contains("node /tmp/ado-aw-scripts/prompt.js"), + "default path must invoke prompt.js: \n{compiled}" + ); + assert!( + compiled.contains("ADO_AW_PROMPT_SPEC:"), + "default path must emit ADO_AW_PROMPT_SPEC env: \n{compiled}" + ); + assert!( + compiled.contains("Render agent prompt"), + "default path must label step `Render agent prompt`: \n{compiled}" + ); + assert!( + compiled.contains("Install Node.js 20.x for prompt renderer"), + "default path must install Node in the Agent job: \n{compiled}" + ); + assert!( + compiled.contains("ado-script.zip"), + "default path must download the scripts bundle: \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}" + ); +} + +/// Default mode: the embedded `PromptSpec` resolves `source_path` +/// against `$(Build.SourcesDirectory)` (i.e. `{{ trigger_repo_directory }}` +/// is substituted at compile time so prompt.js sees a fully resolved +/// path). +#[test] +fn test_runtime_prompt_default_spec_resolves_source_path() { + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + + let compiled = compile_fixture("runtime-prompt-default-agent.md"); + + // Extract the base64 spec from the emitted env line. + let needle = "ADO_AW_PROMPT_SPEC: \""; + let start = compiled.find(needle).expect("spec env not found") + needle.len(); + let end = compiled[start..] + .find('"') + .expect("closing quote on spec env not found") + + start; + let b64 = &compiled[start..end]; + let json_bytes = BASE64 + .decode(b64.as_bytes()) + .expect("base64 spec must decode"); + let json = String::from_utf8(json_bytes).expect("spec must be UTF-8"); + + assert!( + json.contains("\"version\":1"), + "spec must pin schema version 1: {json}" + ); + assert!( + json.contains("\"source_path\":\"$(Build.SourcesDirectory)/runtime-prompt-default-agent.md\""), + "spec source_path must be fully resolved at compile time: {json}" + ); + assert!( + json.contains("\"output_path\":\"/tmp/awf-tools/agent-prompt.md\""), + "spec output_path must point at the canonical AWF path: {json}" + ); + // Supplements may include SafeOutputs (always-on extension) — assert + // shape rather than exact contents. + assert!( + json.contains("\"supplements\":["), + "spec must include supplements array: {json}" + ); +} + +/// Inlined mode (`inlined-imports: true`): body is embedded verbatim in a +/// heredoc step and prompt.js is NOT invoked. +#[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 prompt.js: \n{compiled}" + ); + assert!( + !compiled.contains("ADO_AW_PROMPT_SPEC"), + "inlined branch must NOT emit the runtime env contract: \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); }