feat(compile): runtime prompt injection via prompt.js bundle#617
feat(compile): runtime prompt injection via prompt.js bundle#617jamesadevine wants to merge 1 commit into
Conversation
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>
🔍 Rust PR ReviewSummary: Good architecture with one load-bearing runtime bug that will cause the default path to always fail in a real ADO pipeline. Findings🐛 Bugs / Logic Issues
if (!fs.existsSync(spec.source_path)) { ... process.exit(1); }
const source = fs.readFileSync(spec.source_path, "utf8");At that point The fix is to resolve ADO variable references in // Resolve ADO variable refs in the path before touching the filesystem.
const resolvedSourcePath = substitute(spec.source_path, [], (msg) => logWarning(msg));
if (!fs.existsSync(resolvedSourcePath)) { ... }
const source = fs.readFileSync(resolvedSourcePath, "utf8");The smoke tests don't catch this because they set
|
|
Superseded by #623, which rebuilds this work along gh-aw lines after observing how their generated lock yamls compose prompts (
Net diff: 1213 / 165 in #623 vs 1914 / 197 here. No Agent-VM Node install for prompt rendering. Net win on lock-yaml readability. Closing in favour of #623. |
Summary
Replaces compile-time prompt embedding with runtime prompt injection via a new
prompt.jsado-script bundle. The default behaviour changes: the agent's markdown body is no longer baked into the compiled pipeline YAML — it's read from the workspace at runtime byprompt.js, which also strips front matter, appends extension supplements, and substitutes pipeline parameters / variables.UX win: body-only edits to the agent
.mdno longer require recompiling the pipeline. Front-matter changes still recompile (correct: front-matter is configuration, body is prompt).Supersedes #395. That PR was 155 commits behind main and predated the gate.js/
ado-script.zipinfrastructure from #389. This is a fresh rebuild on current main that:/tmp/ado-aw-scripts/gate.js,/tmp/ado-aw-scripts/prompt.js) instead of leaking the internalado-script/dist/<bundle>/index.jspath. Versioned zip → no cross-version contract → clean cutover.needs_scripts_bundle()on theCompilerExtensiontrait).prompt.js(blocks the parameter-value-into-$(VAR)chaining attack).strip_prefix(" ")instead oftrim_start_matches(' ')in the inlined branch (preserves author-supplied leading whitespace on the first body line).tests/compiler_tests.rsexercising the fullcompile_sharedround-trip with body-absent and PromptSpec-present assertions.Set
inlined-imports: truein front matter to opt out and keep the legacy heredoc-embedded behaviour. Use this for self-contained YAML, restricted networks, or debugging.Design
The runtime contract is a new
PromptSpecIR mirroring the provenGateSpecpattern:src/compile/prompt_ir.rs—PromptSpec,PromptSupplement,PROMPT_SPEC_VERSION = 1, schemars-derived JSON Schema.export-prompt-schema(mirrorsexport-gate-schema).npm run codegennow produces bothtypes.gen.ts(gate) andtypes-prompt.gen.ts(prompt). CI drift check covers both.PromptSpecis JSON-serialized + base64-encoded into a singleADO_AW_PROMPT_SPECenv var on the prompt.js step. Each declared parameter gets a separateADO_AW_PARAM_<NAME>: ${{ parameters.<NAME> }}env entry — values stay outside the spec so ADO secret-redaction and late binding work correctly.Substitution semantics (single-pass)
prompt.jswalks the rendered prompt body+supplements exactly once with one regex matching all supported token shapes. Replacement values are returned verbatim and never re-scanned:\$(VAR)/\$(VAR.SUB)$(...)left literal.${{ parameters.NAME }}ADO_AW_PARAM_<NAME upper, hyphen→underscore>$(VAR)/$(VAR.SUB)<name upper, dot→underscore>(ADO native)$[ ... ]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. Unit and smoke tests assert this property directly.Pipeline ordering
When
inlined-imports: false(default), the new{{ prepare_agent_prompt }}template marker emits a three-step bundle in the Agent job:NodeTool@0to install Node 20.x (timeout-capped at 5 minutes).bash:download ofado-script.zip(with checksums.txt SHA-256 verify) and unzip into/tmp/ado-aw-scripts/.bash: node /tmp/ado-aw-scripts/prompt.jswith the spec env + per-parameter env mappings.When
inlined-imports: true, the same marker emits the legacy heredoc step embedding the body verbatim, plus per-extensioncat >>supplement steps via the existingwrap_prompt_append.Scope
.md.PromptSpec.supplements.${{ parameters.* }}and$(VAR)patterns, single-pass.needs_scripts_bundle()on the extension trait, dedup'd within each consuming job.gate.js/prompt.js.{{#import path }}runtime-import macros — out of scope; the spec is additive-friendly so this is not a one-way door.Files changed
Rust (compile-time)
src/compile/prompt_ir.rs(new) —PromptSpec,PromptSupplement,PROMPT_SPEC_VERSION, schema generator.src/compile/mod.rs—pub(crate) mod prompt_ir;.src/compile/types.rs— addsinlined_imports: bool(#[serde(rename = "inlined-imports", default)]) toFrontMatter+ 4 round-trip tests.src/compile/common.rs— new helperscollect_prompt_supplements,generate_prepare_agent_prompt;generate_prepare_stepstakesinlined_imports: booland skipswrap_prompt_appendin the runtime branch;compile_sharedbuilds and emits{{ prepare_agent_prompt }}. Includes thestrip_prefix(" ")fix.src/compile/extensions/mod.rs— newneeds_scripts_bundle()trait method,node_tool_step+scripts_download_step+scripts_install_steps_if_neededshared helpers.src/compile/extensions/trigger_filters.rs— refactored to declareneeds_scripts_bundle()instead of inlining the install pair.src/compile/filter_ir.rs— gate path constants updated to flattened/tmp/ado-aw-scripts/gate.js.src/main.rs— adds hiddenCommands::ExportPromptSchemasubcommand.src/data/{base,1es-base,job-base,stage-base}.yml— replace static heredoc step with{{ prepare_agent_prompt }}.TypeScript (runtime bundle)
scripts/ado-script/src/prompt/index.ts(new) — entry point; decodesADO_AW_PROMPT_SPEC, validates version, reads source, strips front matter, assembles body+supplements, runs single-pass substitute, writes output.scripts/ado-script/src/prompt/frontmatter.ts(new) — purestripFrontMattermirroringparse_markdown_detailedsemantics from Rust.scripts/ado-script/src/prompt/substitute.ts(new) — single-pass substitution engine (regex with named groups; values never re-scanned).scripts/ado-script/src/prompt/__tests__/{frontmatter,substitute}.test.ts(new) — 21 unit tests including the parameter-chains-into-VAR attack case.scripts/ado-script/src/shared/types-prompt.gen.ts(new, AUTO-GENERATED) — committed; CI verifies it stays in lockstep with the Rust IR.scripts/ado-script/test/prompt-smoke.test.ts(new) — 4 end-to-end smoke tests against the ncc-built bundle including the chained-substitution attack.scripts/ado-script/package.json— extendscodegen(nowcodegen:gate+codegen:prompt); addsbuild:prompt; updatesbuildandbuild:checkto cover both bundles.scripts/ado-script/src/gate/predicates.ts— error-message reference updated for the flattened bundle path.CI / infra
.github/workflows/ado-script.yml— drift check now covers bothtypes.gen.tsandtypes-prompt.gen.ts; build step builds both bundles; smoke step runs both bundles..github/workflows/release.yml— package step flattensdist/<bundle>/index.js→ top-level<bundle>.jsinsideado-script.zip.Docs
docs/ado-script.md— new "Whatprompt.jsdoes" section, updated workspace layout, updated codegen section, updated download-wiring section.docs/front-matter.md— documentsinlined-imports: trueand the rendering model.docs/template-markers.md— replaces{{ agent_content }}with{{ prepare_agent_prompt }}and documents the substitution contract.docs/extending.md— addsneeds_scripts_bundle()trait method and notes thatprompt_supplement()content travels throughPromptSpec.supplements(not heredoc steps) by default.docs/filter-ir.md— gate paths updated to the flattened layout.AGENTS.md— addsprompt.jsandprompt_ir.rsto the source-tree overview.Test fixtures
tests/fixtures/runtime-prompt-default-agent.md(new) — drives the runtime-path integration assertions.tests/fixtures/runtime-prompt-inlined-agent.md(new) — drives the inlined-path integration assertions.tests/compiler_tests.rs— 4 new integration tests covering both modes +PromptSpecspec decoding + body-absence.Test plan
cargo build✓cargo test✓ — 1605 lib + 95 compiler_tests + smaller test files, 0 failures. Includes:prompt_irtests (schema/version/serialization).inlined-importsfield tests incompile::types.generate_prepare_agent_prompt/collect_prompt_supplementstests incompile::common.trigger_filterstests covering the dedupe.compiler_tests.rs(asserts on PromptSpec base64 contents, body absence, heredoc presence).cargo clippy --all-targets --all-features✓ — no new lints (baseline pre-existed).cd scripts/ado-script && npm test✓ — 214 tests pass (193 baseline + 21 new for prompt.js: 8 frontmatter + 13 substitute).cd scripts/ado-script && npm run typecheck✓ — clean.cd scripts/ado-script && npx vitest run -c vitest.config.smoke.ts✓ — 6 tests pass (2 gate.js smoke + 4 new prompt.js end-to-end including the chained-substitution attack).examples/sample-agent.md:node /tmp/ado-aw-scripts/prompt.js+ADO_AW_PROMPT_SPECenv, the prompt body is absent from the YAML, and decoding the base64 spec confirmssource_pathresolves to$(Build.SourcesDirectory)/sample-agent.md({{ trigger_repo_directory }}substituted before encoding).inlined-imports: true, YAML contains the body verbatim inside anAGENT_PROMPT_EOFheredoc and does not invoke prompt.js.Migration
Default behaviour changes. Existing compiled
.lock.ymlfiles will failado-aw checkafter this lands until the consumer recompiles. The integrity check property strengthens: with the body no longer embedded, prose-only edits no longer drift from the compiled YAML at all.inlined-imports: trueis the one-line escape hatch for users who can't recompile immediately, can't reach github.com from the Agent pool, or want a fully self-contained pipeline file.