diff --git a/AGENTS.md b/AGENTS.md index 7bab0a0b..c5925ee5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -265,6 +265,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ ├── exec-context-repo/ # Repository identity context source (bundled to exec-context-repo.js) │ ├── conclusion/ # Conclusion-job reporter source (bundled to conclusion.js) │ ├── approval-summary/ # Safe-outputs summary renderer (bundled to approval-summary.js; end-of-Agent-job summary tab) +│ ├── github-app-token/ # GitHub App token minter (bundled to github-app-token.js; mints installation token in Agent + Detection when engine.github-app-token is set) │ └── shared/ # Shared modules across bundles (auth, ado-client, env-facts, types.gen.ts) ├── tests/ # Integration tests and fixtures ├── docs/ # Per-concept reference documentation (see index below) @@ -380,7 +381,11 @@ index to jump to the right page. - [`docs/ado-script.md`](docs/ado-script.md) — `ado-script` workspace (`scripts/ado-script/`): the bundled TypeScript runtime helpers (`gate.js`, `import.js`, the execution-context `exec-context-*.js` - bundles, `conclusion.js`, and `approval-summary.js`), schemars-driven +- [`docs/ado-script.md`](docs/ado-script.md) — `ado-script` workspace + (`scripts/ado-script/`): the bundled TypeScript runtime helpers + (`gate.js`, `import.js`, the execution-context `exec-context-*.js` + bundles, `conclusion.js`, `approval-summary.js`, and + `github-app-token.js`), schemars-driven type codegen, the A2 design decision, and the bundle env contract modelled in `src/compile/ado_bundle.rs`. - [`docs/local-development.md`](docs/local-development.md) — local development diff --git a/docs/ado-script.md b/docs/ado-script.md index 75c827d3..bf2c5fc1 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -56,6 +56,18 @@ pipeline** as runtime helpers. Today it produces thirteen bundles: review is configured), and attaches it to the build's `ado-aw-safe-outputs` summary tab via `##vso[task.uploadsummary]`. See [`safe-outputs.md`](safe-outputs.md). +- `github-app-token.js` — GitHub App token minter that runs immediately + before the Copilot invocation in the **Agent and Detection jobs** when + `engine.github-app-token` is configured. Builds an RS256 JWT from the App ID + (argv) + private key (masked env secret), resolves the installation for the + owner, exchanges it for an installation access token, and exposes it as a + masked same-job `GITHUB_APP_TOKEN`. Invoked again with a `revoke` argument + after the Copilot run (best-effort) to delete the token via + `DELETE /installation/token`. Compiler-owned, non-secret inputs (`--app-id`, + `--owner`, `--output-var`, `--repositories`, `--api-url`) are argv flags, not + env vars, so a pipeline variable can't shadow them (only the private key / + minted token ride in masked env). Runs outside AWF. See + [`engine.md`](engine.md#github-app-backed-copilot-engine-auth). > **Internal-only.** `ado-script` is not a user-facing front-matter > feature. Authors never write an `ado-script:` block in their agent @@ -328,6 +340,16 @@ collection URI is likewise read from the auto-injected `SYSTEM_COLLECTIONURI` (`scripts/ado-script/src/shared/auth.ts::getWebApi`, see #1307), so it is never part of a step's env contract. +Not every bundle authenticates to Azure DevOps. The `github-app-token` bundle +(issue #1316) is `BundleAuth::None`: it authenticates to the **GitHub** API with +its own App JWT / minted installation token, so it carries no +`SYSTEM_ACCESSTOKEN` and no ADO predefined mirrors. Its only env var is the +masked private-key secret (`GH_APP_PRIVATE_KEY`, and `GH_APP_TOKEN` for revoke); +every other, non-secret input (App ID, owner, repositories, output-variable +name, api-url) is a single-quoted **argv flag** rather than an env var, so a +pipeline variable can never shadow it (ADO injects pipeline variables into a +step's env, but argv comes only from the compiler-authored script). + ## End-to-end data flow @@ -492,6 +514,9 @@ scripts/ado-script/ │ └── conclusion/ # conclusion.js entry point + Conclusion-job reporter │ ├── index.ts # main(): inspect upstream results + safe-outputs manifest → file/append work items │ └── __tests__/ # unit tests for signal detection and work-item filing behaviour +│ └── github-app-token/ # github-app-token.js entry point + GitHub App token minter +│ ├── index.ts # main(): RS256 JWT → resolve installation → mint installation token → masked GITHUB_APP_TOKEN +│ └── __tests__/ # unit tests for JWT signing / installation resolution / token minting ├── test/ # End-to-end smoke tests (gate, import, exec-context-pr) ├── gate.js # ncc bundle output (gitignored) ├── import.js # ncc bundle output (gitignored) @@ -505,23 +530,19 @@ scripts/ado-script/ ├── exec-context-pr-checks.js # ncc bundle output (gitignored) ├── exec-context-repo.js # ncc bundle output (gitignored) ├── conclusion.js # ncc bundle output (gitignored) -└── approval-summary.js # ncc bundle output (gitignored) +├── approval-summary.js # ncc bundle output (gitignored) +└── github-app-token.js # ncc bundle output (gitignored) ``` The release workflow (`.github/workflows/release.yml`) runs -`npm ci && npm run build`, then zips `scripts/ado-script/gate.js`, -`scripts/ado-script/import.js`, -`scripts/ado-script/exec-context-pr.js`, -`scripts/ado-script/exec-context-pr-synth.js`, -`scripts/ado-script/exec-context-manual.js`, -`scripts/ado-script/exec-context-pipeline.js`, -`scripts/ado-script/exec-context-ci-push.js`, -`scripts/ado-script/exec-context-workitem.js`, -`scripts/ado-script/exec-context-schedule.js`, -`scripts/ado-script/exec-context-pr-checks.js`, -`scripts/ado-script/exec-context-repo.js`, -`scripts/ado-script/conclusion.js`, and -`scripts/ado-script/approval-summary.js` into the +`npm ci && npm run build`, then globs `scripts/ado-script/*.js` — which +captures every bundle, including `gate.js`, `import.js`, +`exec-context-pr.js`, `exec-context-pr-synth.js`, +`exec-context-manual.js`, `exec-context-pipeline.js`, +`exec-context-ci-push.js`, `exec-context-workitem.js`, +`exec-context-schedule.js`, `exec-context-pr-checks.js`, +`exec-context-repo.js`, `conclusion.js`, `approval-summary.js`, and +`github-app-token.js` — 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. diff --git a/docs/engine.md b/docs/engine.md index aad64632..8aa948b6 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -30,6 +30,7 @@ engine: | `args` | list | `[]` | Custom CLI arguments appended after compiler-generated args. Subject to shell-safety validation and blocked from overriding compiler-controlled flags (`--prompt`, `--additional-mcp-config`, `--allow-tool`, `--allow-all-tools`, `--allow-all-paths`, `--disable-builtin-mcps`, `--no-ask-user`, `--ask-user`). | | `env` | map | *(none)* | Engine-specific environment variables merged into the sandbox step's `env:` block. Keys must be valid env var names. Values are literal-only and must not contain ADO expressions (`$(`, `${{`, `$[`) or pipeline command injection (`##vso[`), **except** the Copilot BYOM provider keys (`COPILOT_PROVIDER_BASE_URL`, `COPILOT_PROVIDER_API_KEY`, `COPILOT_PROVIDER_BEARER_TOKEN`, `COPILOT_PROVIDER_WIRE_API`), which may carry an ADO macro (`$(...)`) expression — see [Copilot BYOM / BYOK provider configuration](#copilot-byom--byok-provider-configuration). Compiler-controlled keys (`GITHUB_TOKEN`, `PATH`, `BASH_ENV`, etc.) are blocked. | | `command` | string | *(none)* | Custom engine executable path (skips the default engine binary installation — NuGet for `target: 1es`, GitHub Releases for all other targets). The path must be accessible inside the AWF container (e.g., `/tmp/...` or workspace-mounted paths). | +| `github-app-token` | map | *(none)* | GitHub App-backed Copilot engine authentication. When set, the compiler mints (and, by default, revokes) a GitHub App installation token in the Agent and Detection jobs and sources `GITHUB_TOKEN` from it (for Copilot only). See [GitHub App-backed Copilot engine auth](#github-app-backed-copilot-engine-auth). | ### `timeout-minutes` @@ -42,6 +43,118 @@ The `timeout-minutes` field sets a wall-clock limit (in minutes) for the entire When omitted, Azure DevOps uses its default job timeout (60 minutes). When set, the compiler emits `timeoutInMinutes: ` on the agentic job. +### GitHub App-backed Copilot engine auth + +By default the Copilot engine authenticates with the `GITHUB_TOKEN` pipeline +variable you provide (`ado-aw secrets set GITHUB_TOKEN `). For +organization-backed Copilot authentication you can instead have the compiler +mint a **GitHub App installation access token** at runtime — mirroring +[gh-aw's](https://github.com/githubnext/gh-aw) `create-github-app-token` model, +adapted to Azure DevOps. + +```yaml +engine: + id: copilot + github-app-token: + app-id: 1234567 # literal App ID or client ID (required) + owner: octo-org # installation owner (org or user login) + repositories: [octo-repo] # optional; scopes the token to these repos + # api-url: https://ghe.example.com/api/v3 # optional; GHES base URL + # skip-token-revocation: false # optional; revoke by default + # private-key: MY_SECRET_VAR # optional override; see below +``` + +Store the private key once and you're done: + +```bash +ado-aw secrets set GITHUB_APP_PRIVATE_KEY "$(cat app-private-key.pem)" +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `app-id` | yes | The GitHub App ID — a **literal** value: either a numeric App ID (e.g. `1234567`, quoted or unquoted) or an alphanumeric client ID (e.g. `Iv23liABC…`). The App ID is not secret (it is plain per-app config, like `owner`), so it is written verbatim, not indirected through a variable. | +| `owner` | yes | GitHub installation owner (organization or user login). | +| `repositories` | no | Repository names (owner-relative) to scope the installation token to. Omit to span every repository the installation grants. | +| `api-url` | no | GitHub API base URL. Defaults to `https://api.github.com` (GHEC). For GitHub Enterprise Server, set the `/api/v3` base URL (e.g. `https://ghe.example.com/api/v3`). Must be an `https://` URL. | +| `skip-token-revocation` | no | When `true`, do not revoke the minted token after the Copilot run. Defaults to `false` (the token is revoked — see below). | +| `private-key` | no | Name of the ADO **secret** pipeline variable holding the private key (PEM). **Defaults to `GITHUB_APP_PRIVATE_KEY`** — the compiler owns the name, exactly like `GITHUB_TOKEN`, so the common case sets no field and just runs `ado-aw secrets set GITHUB_APP_PRIVATE_KEY …`. Set this only to point at a differently-named secret (e.g. reusing an existing variable). | + +#### Setup + +1. Create the GitHub App and install it on the owning org/user, ensuring the + installation is suitable for Copilot organization-backed + authentication/billing in your tenant. Copilot capability is granted on the + **App/org side** — the installation token inherits it automatically; you do + not (and cannot) configure it here. +2. Store the private key as an ADO **secret** pipeline variable — the default + name `GITHUB_APP_PRIVATE_KEY` unless you set a `private-key` override: + + ```bash + ado-aw secrets set GITHUB_APP_PRIVATE_KEY "$(cat app-private-key.pem)" + ``` +3. Set `engine.github-app-token` (`app-id` + `owner` at minimum) and compile. + Each compile prints a non-blocking advisory reminding you to store the + private-key variable as a secret — this is expected (the compiler cannot + verify secrecy itself). + +#### What the compiler generates + +- A **token-mint step** (the `github-app-token` ado-script bundle) runs + immediately before the Copilot invocation in **both** the Agent and Detection + jobs. It builds a short-lived RS256 JWT from the App ID + private key, + resolves the installation for `owner`, exchanges it for an installation + access token (optionally scoped to `repositories`), and exposes it as a + **masked, same-job** `GITHUB_APP_TOKEN` variable. +- The Copilot engine's `GITHUB_TOKEN` is then sourced from `$(GITHUB_APP_TOKEN)` + instead of the operator-provided `$(GITHUB_TOKEN)` variable. +- A **token-revocation step** runs after the Copilot invocation in both jobs + (unless `skip-token-revocation: true`). It deletes the minted token + (`DELETE /installation/token`) so it does not remain valid for its full ~1h + lifetime — matching `actions/create-github-app-token`'s default. Revocation is + best-effort (`always()` + `continueOnError`) and never fails the build. +- The token is **never** provided to SafeOutputs, user-authored `steps:`, + ManualReview, Teardown, or Conclusion. + +The mint/revoke steps run **outside** the AWF network sandbox (like the +ADO-token and execution-context steps), reaching the GitHub API over the build +agent pool's normal network — no AWF `network.allowed` entry is required. + +#### Scope and boundaries — what this token is *not* + +- **ADO API permissions** (`permissions.read` / `permissions.write`) are + entirely separate: they describe Azure DevOps OAuth/service-connection tokens + used by the pipeline and Stage 3 executor. The GitHub App token has no effect + on them. +- The GitHub App token is for **Copilot engine authentication only**. It does + **not** authenticate the `gh` CLI, grant GitHub MCP permissions, or give the + agent sandbox GitHub write access. +- There is **no `permissions:` sub-field** to scope the token. Copilot access + rides on the App's own granted capability (configured App/org-side); narrowing + the installation token's permissions at mint time cannot grant Copilot access + and risks stripping the capability it needs. + +#### Notes and limitations + +- **Copilot engine only.** `github-app-token` is rejected at compile time for + any other `engine.id` (the minted token is wired into `GITHUB_TOKEN` only on + the Copilot path), so a misconfiguration fails fast rather than silently + no-opping. +- **Secrecy is your responsibility.** The compiler validates the private-key + *variable name* but cannot verify the ADO variable is actually marked secret, + so every compile of a `github-app-token` workflow emits an advisory: + `Warning: engine.github-app-token uses pipeline variable '' … Ensure + '' is stored as a SECRET …`. It is non-blocking — heed it by setting the + value with `ado-aw secrets set` (which stores it as a secret). +- **GHEC by default; GHES via `api-url`.** The mint/revoke steps target + `https://api.github.com` unless you set `api-url` to your GitHub Enterprise + Server `/api/v3` base URL. This is independent of `engine.api-target`, which + configures the Copilot API host, not the GitHub App API host. +- **`openssl` is not required** — the token is minted with Node's built-in + crypto. The build agent needs Node (installed automatically) and network + access to the GitHub API host. +- You may still need to pin `engine.version` until the relevant Copilot CLI auth + behavior is broadly available. + ### Copilot BYOM / BYOK provider configuration The Copilot engine can route requests to an external LLM provider — for example a diff --git a/prompts/create-ado-agentic-workflow.md b/prompts/create-ado-agentic-workflow.md index a51a2bd7..0ab80da1 100644 --- a/prompts/create-ado-agentic-workflow.md +++ b/prompts/create-ado-agentic-workflow.md @@ -81,6 +81,25 @@ engine: timeout-minutes: 30 ``` +**Org-backed Copilot auth (GitHub App).** By default Copilot authenticates with +the `GITHUB_TOKEN` pipeline variable. For organization-backed authentication, +add a `github-app-token` block so the compiler mints a GitHub App installation +token at runtime (Copilot engine only): +```yaml +engine: + id: copilot + github-app-token: + app-id: 1234567 # literal App ID or client ID + owner: octo-org # installation owner (org or user) + # repositories: [octo-repo] # optional; scopes the token +``` +Store the private key once with `ado-aw secrets set GITHUB_APP_PRIVATE_KEY +"$(cat key.pem)"`. Only add this when the workflow explicitly needs org-backed +Copilot auth — omit it otherwise. See +[`docs/engine.md`](../docs/engine.md#github-app-backed-copilot-engine-auth) for +the full field reference (`private-key` override, `api-url` for GHES, +`skip-token-revocation`). + ### Step 3 — Schedule Use the **fuzzy schedule syntax** (deterministic time scattering based on agent name hash prevents load spikes). Omit `on.schedule` (or omit `on:` entirely) for manual/trigger-only pipelines. diff --git a/prompts/debug-ado-agentic-workflow.md b/prompts/debug-ado-agentic-workflow.md index c59db46a..7e2ecc5f 100644 --- a/prompts/debug-ado-agentic-workflow.md +++ b/prompts/debug-ado-agentic-workflow.md @@ -314,6 +314,7 @@ network: timeout-minutes: 120 ``` - **API rate limiting**: The model provider is rate-limiting requests. Check Copilot CLI logs for 429 responses. +- **GitHub App auth failures** (when `engine.github-app-token` is set): if Copilot fails to authenticate, check the `Mint GitHub App token` step logs in the Agent (and Detection) job. Common causes: the `GITHUB_APP_PRIVATE_KEY` secret (or your `private-key` override) is unset or not marked secret; a wrong `app-id`/`owner` (the App must be installed on that owner); or GHES without `api-url` set to the `/api/v3` base URL. `github-app-token` is Copilot-engine only — the compiler rejects it on other engines. See [`docs/engine.md`](../docs/engine.md#github-app-backed-copilot-engine-auth). ### Agent Tool Errors diff --git a/prompts/update-ado-agentic-workflow.md b/prompts/update-ado-agentic-workflow.md index 8ca6200d..647ab7a1 100644 --- a/prompts/update-ado-agentic-workflow.md +++ b/prompts/update-ado-agentic-workflow.md @@ -228,6 +228,34 @@ permissions: `permissions.write` is **optional** — the Stage 3 executor defaults to `$(System.AccessToken)`. Set it only when you need cross-org writes or named-identity attribution instead of `Project Collection Build Service`. +### Enabling GitHub App-backed Copilot Auth + +Add a `github-app-token` block under `engine:` so Copilot authenticates with an +org-backed GitHub App installation token (Copilot engine only): + +```yaml +engine: + id: copilot + github-app-token: + app-id: 1234567 # literal App ID or client ID + owner: octo-org # installation owner (org or user) + # repositories: [octo-repo] # optional; scopes the token + # api-url: https://ghe.example.com/api/v3 # optional; GHES + # private-key: MY_SECRET_VAR # optional; defaults to GITHUB_APP_PRIVATE_KEY + # skip-token-revocation: false # optional; token is revoked by default +``` + +Then store the private key as a secret: +```bash +ado-aw secrets set GITHUB_APP_PRIVATE_KEY "$(cat app-private-key.pem)" +``` + +Notes: this is distinct from `permissions:` (ADO API tokens) — it only sources +the Copilot engine's `GITHUB_TOKEN`. Compile prints a non-blocking advisory +reminding you to mark the private-key variable secret. See +[`docs/engine.md`](../docs/engine.md#github-app-backed-copilot-engine-auth). +Requires recompilation. + ### Adding Pre/Post Steps **Inline steps** run inside the `Agent` job: diff --git a/scripts/ado-script/.gitignore b/scripts/ado-script/.gitignore index f2b62f07..90f772cb 100644 --- a/scripts/ado-script/.gitignore +++ b/scripts/ado-script/.gitignore @@ -13,5 +13,6 @@ exec-context-pr-checks.js exec-context-repo.js approval-summary.js conclusion.js +github-app-token.js schema *.tsbuildinfo diff --git a/scripts/ado-script/package.json b/scripts/ado-script/package.json index 6c722ded..020b690c 100644 --- a/scripts/ado-script/package.json +++ b/scripts/ado-script/package.json @@ -7,8 +7,8 @@ "node": ">=20.0.0" }, "scripts": { - "build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary", - "clean": "node -e \"const fs=require('node:fs'); fs.rmSync('.ado-build',{recursive:true,force:true}); for (const n of ['gate','import','exec-context-pr','exec-context-pr-synth','exec-context-manual','exec-context-pipeline','exec-context-ci-push','exec-context-workitem','exec-context-schedule','exec-context-pr-checks','exec-context-repo','conclusion','approval-summary']) fs.rmSync(n+'.js',{force:true});\"", + "build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token", + "clean": "node -e \"const fs=require('node:fs'); fs.rmSync('.ado-build',{recursive:true,force:true}); for (const n of ['gate','import','exec-context-pr','exec-context-pr-synth','exec-context-manual','exec-context-pipeline','exec-context-ci-push','exec-context-workitem','exec-context-schedule','exec-context-pr-checks','exec-context-repo','conclusion','approval-summary','github-app-token']) fs.rmSync(n+'.js',{force:true});\"", "build:gate": "ncc build src/gate/index.ts -o .ado-build/gate -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/gate/index.js','gate.js'); fs.rmSync('.ado-build/gate',{recursive:true,force:true});\"", "build:import": "ncc build src/import/index.ts -o .ado-build/import -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/import/index.js','import.js'); fs.rmSync('.ado-build/import',{recursive:true,force:true});\"", "build:exec-context-pr": "ncc build src/exec-context-pr/index.ts -o .ado-build/exec-context-pr -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/exec-context-pr/index.js','exec-context-pr.js'); fs.rmSync('.ado-build/exec-context-pr',{recursive:true,force:true});\"", @@ -22,10 +22,11 @@ "build:exec-context-repo": "ncc build src/exec-context-repo/index.ts -o .ado-build/exec-context-repo -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/exec-context-repo/index.js','exec-context-repo.js'); fs.rmSync('.ado-build/exec-context-repo',{recursive:true,force:true});\"", "build:conclusion": "ncc build src/conclusion/index.ts -o .ado-build/conclusion -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/conclusion/index.js','conclusion.js'); fs.rmSync('.ado-build/conclusion',{recursive:true,force:true});\"", "build:approval-summary": "ncc build src/approval-summary/index.ts -o .ado-build/approval-summary -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/approval-summary/index.js','approval-summary.js'); fs.rmSync('.ado-build/approval-summary',{recursive:true,force:true});\"", + "build:github-app-token": "ncc build src/github-app-token/index.ts -o .ado-build/github-app-token -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/github-app-token/index.js','github-app-token.js'); fs.rmSync('.ado-build/github-app-token',{recursive:true,force:true});\"", "build:check": "ls -lh gate.js && wc -c gate.js", "codegen": "node -e \"require('node:fs').mkdirSync('schema', { recursive: true })\" && 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.\"", "test": "vitest run", - "test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && vitest run -c vitest.config.smoke.ts", + "test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && vitest run -c vitest.config.smoke.ts", "lint": "echo TODO", "typecheck": "tsc --noEmit" }, diff --git a/scripts/ado-script/src/github-app-token/__tests__/index.test.ts b/scripts/ado-script/src/github-app-token/__tests__/index.test.ts new file mode 100644 index 00000000..326ee66b --- /dev/null +++ b/scripts/ado-script/src/github-app-token/__tests__/index.test.ts @@ -0,0 +1,378 @@ +import { describe, it, expect, vi } from "vitest"; +import { generateKeyPairSync, createVerify } from "node:crypto"; + +import { + buildAppJwt, + parseArgs, + parseRepositories, + resolveInstallationId, + mintInstallationToken, + revoke, + main, +} from "../index.js"; + +function makeKeyPair() { + return generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); +} + +function decodeSegment(seg: string): unknown { + const b64 = seg.replace(/-/g, "+").replace(/_/g, "/"); + return JSON.parse(Buffer.from(b64, "base64").toString("utf8")); +} + +/** Build a minimal fetch-like Response stub. */ +function jsonResponse(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + }; +} + +describe("buildAppJwt", () => { + it("produces a verifiable RS256 JWT with iat/exp/iss claims", () => { + const { publicKey, privateKey } = makeKeyPair(); + const now = 1_700_000_000; + const jwt = buildAppJwt("123456", privateKey, now); + const [h, p, s] = jwt.split(".") as [string, string, string]; + expect(h).toBeTruthy(); + expect(p).toBeTruthy(); + expect(s).toBeTruthy(); + + const header = decodeSegment(h) as { alg: string; typ: string }; + expect(header).toEqual({ alg: "RS256", typ: "JWT" }); + + const payload = decodeSegment(p) as { + iat: number; + exp: number; + iss: string; + }; + expect(payload.iss).toBe("123456"); + expect(payload.iat).toBe(now - 60); + expect(payload.exp).toBe(now + 540); + + const verifier = createVerify("RSA-SHA256"); + verifier.update(`${h}.${p}`); + verifier.end(); + const sig = Buffer.from( + s.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ); + expect(verifier.verify(publicKey, sig)).toBe(true); + }); +}); + +describe("parseRepositories", () => { + it("splits on commas, spaces, and newlines and drops blanks", () => { + expect(parseRepositories("a, b\nc d")).toEqual(["a", "b", "c", "d"]); + expect(parseRepositories(undefined)).toEqual([]); + expect(parseRepositories("")).toEqual([]); + expect(parseRepositories(" single ")).toEqual(["single"]); + }); +}); + +describe("resolveInstallationId", () => { + it("returns the id from the org endpoint when it succeeds", async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse(200, { id: 42 })); + const id = await resolveInstallationId( + fetchFn as never, + "https://api.github.com", + "jwt", + "octo-org", + ); + expect(id).toBe(42); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(fetchFn.mock.calls[0]![0]).toContain("/orgs/octo-org/installation"); + }); + + it("falls back to the user endpoint when the org lookup 404s", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(jsonResponse(404, "not found")) + .mockResolvedValueOnce(jsonResponse(200, { id: 7 })); + const id = await resolveInstallationId( + fetchFn as never, + "https://api.github.com", + "jwt", + "octo-user", + ); + expect(id).toBe(7); + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(fetchFn.mock.calls[1]![0]).toContain("/users/octo-user/installation"); + }); + + it("throws when neither endpoint resolves", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse(404, "nope")); + await expect( + resolveInstallationId( + fetchFn as never, + "https://api.github.com", + "jwt", + "ghost", + ), + ).rejects.toThrow(/could not resolve/i); + }); +}); + +describe("mintInstallationToken", () => { + it("scopes to repositories when provided and returns the token", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(jsonResponse(201, { token: "ghs_secret" })); + const token = await mintInstallationToken( + fetchFn as never, + "https://api.github.com", + "jwt", + 99, + ["repo-a", "repo-b"], + ); + expect(token).toBe("ghs_secret"); + const [url, init] = fetchFn.mock.calls[0]!; + expect(url).toContain("/app/installations/99/access_tokens"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ + repositories: ["repo-a", "repo-b"], + }); + }); + + it("omits the repositories field when none are given", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(jsonResponse(201, { token: "ghs_all" })); + await mintInstallationToken( + fetchFn as never, + "https://api.github.com", + "jwt", + 1, + [], + ); + expect(JSON.parse(fetchFn.mock.calls[0]![1].body)).toEqual({}); + }); + + it("throws on a non-2xx response", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(jsonResponse(403, "forbidden")); + await expect( + mintInstallationToken( + fetchFn as never, + "https://api.github.com", + "jwt", + 1, + [], + ), + ).rejects.toThrow(/HTTP 403/); + }); +}); + +describe("parseArgs", () => { + it("parses the mint flags into CliArgs", () => { + const args = parseArgs([ + "--app-id", + "1234567", + "--owner", + "octo-org", + "--output-var", + "GITHUB_APP_TOKEN", + "--repositories", + "repo-a repo-b", + "--api-url", + "https://ghe.example.com/api/v3", + ]); + expect(args).toEqual({ + appId: "1234567", + owner: "octo-org", + outputVar: "GITHUB_APP_TOKEN", + repositories: "repo-a repo-b", + apiUrl: "https://ghe.example.com/api/v3", + }); + }); + + it("ignores unknown flags and tolerates a bare revoke arg list", () => { + expect(parseArgs(["--api-url", "https://x/api/v3"])).toEqual({ + apiUrl: "https://x/api/v3", + }); + expect(parseArgs(["--unknown", "v"])).toEqual({}); + expect(parseArgs([])).toEqual({}); + }); + + it("stays aligned when a value-less boolean flag precedes real flags", () => { + // A future compiler adds `--debug` (no value); an older bundle must not + // swallow the following `--app-id` as --debug's value. + const args = parseArgs([ + "--debug", + "--app-id", + "1234567", + "--owner", + "octo-org", + ]); + expect(args.appId).toBe("1234567"); + expect(args.owner).toBe("octo-org"); + }); + + it("does not consume a following flag as a value", () => { + // `--repositories` with no value (immediately followed by another flag) + // leaves repositories unset rather than eating `--owner`. + const args = parseArgs(["--repositories", "--owner", "octo-org"]); + expect(args.repositories).toBeUndefined(); + expect(args.owner).toBe("octo-org"); + }); +}); + +describe("main", () => { + it("mints a token and emits a masked same-job variable", async () => { + const { privateKey } = makeKeyPair(); + const fetchFn = vi + .fn() + .mockResolvedValueOnce(jsonResponse(200, { id: 55 })) + .mockResolvedValueOnce(jsonResponse(201, { token: "ghs_minted" })); + const writes: string[] = []; + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(chunk.toString()); + return true; + }); + + const rc = await main( + { + appId: "123", + owner: "octo-org", + outputVar: "GITHUB_APP_TOKEN", + repositories: "repo-a repo-b", + }, + { GH_APP_PRIVATE_KEY: privateKey } as NodeJS.ProcessEnv, + fetchFn as never, + ); + spy.mockRestore(); + + expect(rc).toBe(0); + const out = writes.join(""); + expect(out).toContain( + "##vso[task.setvariable variable=GITHUB_APP_TOKEN;issecret=true]ghs_minted", + ); + }); + + it("honours --api-url and --output-var", async () => { + const { privateKey } = makeKeyPair(); + const fetchFn = vi + .fn() + .mockResolvedValueOnce(jsonResponse(200, { id: 1 })) + .mockResolvedValueOnce(jsonResponse(201, { token: "ghs_ghes" })); + const writes: string[] = []; + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(chunk.toString()); + return true; + }); + + const rc = await main( + { + appId: "1", + owner: "acme", + apiUrl: "https://ghes.example.com/api/v3/", + outputVar: "CUSTOM_TOKEN", + }, + { GH_APP_PRIVATE_KEY: privateKey } as NodeJS.ProcessEnv, + fetchFn as never, + ); + spy.mockRestore(); + + expect(rc).toBe(0); + // Trailing slash stripped; org endpoint queried on the GHES host. + expect(fetchFn.mock.calls[0]![0]).toBe( + "https://ghes.example.com/api/v3/orgs/acme/installation", + ); + expect(writes.join("")).toContain( + "##vso[task.setvariable variable=CUSTOM_TOKEN;issecret=true]ghs_ghes", + ); + }); + + it("returns 1 and logs an error when a required arg is missing", async () => { + const writes: string[] = []; + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(chunk.toString()); + return true; + }); + // --owner missing. + const rc = await main( + { appId: "1" }, + { GH_APP_PRIVATE_KEY: "key" } as NodeJS.ProcessEnv, + vi.fn() as never, + ); + spy.mockRestore(); + expect(rc).toBe(1); + expect(writes.join("")).toContain("--owner"); + }); + + it("returns 1 when the private key env secret is missing", async () => { + const writes: string[] = []; + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array): boolean => { + writes.push(chunk.toString()); + return true; + }); + const rc = await main( + { appId: "1", owner: "acme" }, + {} as NodeJS.ProcessEnv, + vi.fn() as never, + ); + spy.mockRestore(); + expect(rc).toBe(1); + expect(writes.join("")).toContain("GH_APP_PRIVATE_KEY"); + }); +}); + +describe("revoke", () => { + it("DELETEs the installation token and returns 0", async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse(204, "")); + const rc = await revoke( + { apiUrl: "https://ghe.example.com/api/v3/" }, + { GH_APP_TOKEN: "ghs_minted" } as NodeJS.ProcessEnv, + fetchFn as never, + ); + expect(rc).toBe(0); + const [url, init] = fetchFn.mock.calls[0]!; + expect(url).toBe("https://ghe.example.com/api/v3/installation/token"); + expect(init.method).toBe("DELETE"); + expect(init.headers.Authorization).toBe("Bearer ghs_minted"); + }); + + it("is a no-op (returns 0) when no token is present", async () => { + const fetchFn = vi.fn(); + const rc = await revoke({}, {} as NodeJS.ProcessEnv, fetchFn as never); + expect(rc).toBe(0); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("never fails the build when the DELETE errors", async () => { + const fetchFn = vi.fn().mockRejectedValueOnce(new Error("network down")); + const rc = await revoke( + {}, + { GH_APP_TOKEN: "ghs_minted" } as NodeJS.ProcessEnv, + fetchFn as never, + ); + expect(rc).toBe(0); + }); + + it("tolerates a non-2xx DELETE response", async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse(404, "gone")); + const rc = await revoke( + {}, + { GH_APP_TOKEN: "ghs_minted" } as NodeJS.ProcessEnv, + fetchFn as never, + ); + expect(rc).toBe(0); + }); +}); diff --git a/scripts/ado-script/src/github-app-token/index.ts b/scripts/ado-script/src/github-app-token/index.ts new file mode 100644 index 00000000..b76083a3 --- /dev/null +++ b/scripts/ado-script/src/github-app-token/index.ts @@ -0,0 +1,397 @@ +/** + * github-app-token — mint a GitHub App installation access token for the + * Copilot engine (issue #1316). + * + * Mirrors gh-aw's `create-github-app-token` model, adapted to Azure DevOps: + * the GitHub App **App ID** and **private key** are supplied via env vars + * sourced from ADO pipeline (secret) variables, so no secret material ever + * appears in the compiled pipeline source. + * + * Flow: + * 1. Build a short-lived RS256 JWT signed with the App private key + * (`node:crypto` — no `openssl`, no npm dep). + * 2. Resolve the installation ID for the configured owner + * (`GET /orgs/{owner}/installation`, falling back to + * `GET /users/{owner}/installation`). + * 3. Exchange the JWT for an installation access token + * (`POST /app/installations/{id}/access_tokens`), optionally scoped to a + * set of repositories. + * 4. Emit the token as a **masked, same-job** pipeline variable + * (`##vso[task.setvariable …;issecret=true]`) so a downstream step in the + * same job (the Copilot invocation) can read it via `$(GITHUB_APP_TOKEN)` + * without it leaking into the log. + * + * Trust boundary: runs OUTSIDE the AWF sandbox (a normal ADO script step, like + * the exec-context bundles). It reaches the GitHub API host over the build + * agent pool's normal network — no AWF allowlist entry is required. The private + * key is read from the process env (set from a `$(VAR)` macro) and never + * written to disk. + * + * Invocation contract. Compiler-owned, **non-secret** inputs are passed as argv + * flags (immune to ADO pipeline-variable shadowing — see `CliArgs`); the two + * **secrets** stay in env as `$(secret)` macros so ADO masks them and they never + * appear in the step's command line. + * + * Mint: node github-app-token.js \ + * --app-id --owner --output-var \ + * [--repositories "a b"] [--api-url https://host/api/v3] + * env: GH_APP_PRIVATE_KEY (required, secret) + * + * Revoke: node github-app-token.js revoke [--api-url https://host/api/v3] + * env: GH_APP_TOKEN (the minted token, secret) + * + * Flags: `--app-id` the GitHub App ID; `--owner` installation owner (org/user); + * `--output-var` the masked variable name to set (compiler-pinned, defaults to + * `GITHUB_APP_TOKEN`); `--repositories` space/comma-separated repo names to + * scope the token to; `--api-url` API base URL (default `https://api.github.com`, + * GHES uses `https:///api/v3`). + */ +import { createSign } from "node:crypto"; + +import { logError, logInfo, logWarning, setSecretVar } from "../shared/vso-logger.js"; + +const DEFAULT_API_URL = "https://api.github.com"; +const DEFAULT_OUTPUT_VAR = "GITHUB_APP_TOKEN"; +/** JWT lifetime: 9 minutes (GitHub caps App JWTs at 10). */ +const JWT_TTL_SECONDS = 540; +/** Small clock-skew backdating for `iat` to tolerate agent/GitHub clock drift. */ +const JWT_IAT_BACKDATE_SECONDS = 60; + +function base64url(input: Buffer | string): string { + const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input; + return buf + .toString("base64") + .replace(/=+$/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +/** + * Build a signed RS256 JWT for authenticating as the GitHub App. + * `nowSeconds` is injectable for deterministic tests. + */ +export function buildAppJwt( + appId: string, + privateKeyPem: string, + nowSeconds: number = Math.floor(Date.now() / 1000), +): string { + const header = { alg: "RS256", typ: "JWT" }; + const payload = { + iat: nowSeconds - JWT_IAT_BACKDATE_SECONDS, + exp: nowSeconds + JWT_TTL_SECONDS, + iss: appId, + }; + const signingInput = `${base64url(JSON.stringify(header))}.${base64url( + JSON.stringify(payload), + )}`; + const signer = createSign("RSA-SHA256"); + signer.update(signingInput); + signer.end(); + const signature = base64url(signer.sign(privateKeyPem)); + return `${signingInput}.${signature}`; +} + +/** Parse the repositories env var into a clean list of names. */ +export function parseRepositories(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +interface FetchLike { + ( + url: string, + init: { + method: string; + headers: Record; + body?: string; + }, + ): Promise<{ + ok: boolean; + status: number; + json(): Promise; + text(): Promise; + }>; +} + +function ghHeaders(bearer: string): Record { + return { + Authorization: `Bearer ${bearer}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ado-aw-github-app-token", + }; +} + +/** + * Resolve the installation ID for `owner`. Tries the org endpoint first, then + * the user endpoint (GitHub App installations exist on either an org or a + * user account). + */ +export async function resolveInstallationId( + fetchFn: FetchLike, + apiUrl: string, + jwt: string, + owner: string, +): Promise { + const candidates = [ + `${apiUrl}/orgs/${encodeURIComponent(owner)}/installation`, + `${apiUrl}/users/${encodeURIComponent(owner)}/installation`, + ]; + let lastStatus = 0; + let lastBody = ""; + for (const url of candidates) { + const resp = await fetchFn(url, { + method: "GET", + headers: ghHeaders(jwt), + }); + if (resp.ok) { + const data = (await resp.json()) as { id?: number }; + if (typeof data.id === "number") { + return data.id; + } + throw new Error( + `installation lookup for '${owner}' returned no numeric id`, + ); + } + lastStatus = resp.status; + lastBody = await resp.text(); + } + throw new Error( + `could not resolve a GitHub App installation for owner '${owner}' ` + + `(last HTTP ${lastStatus}): ${lastBody}`, + ); +} + +/** + * Exchange the App JWT for an installation access token, optionally scoped to + * `repositories`. + */ +export async function mintInstallationToken( + fetchFn: FetchLike, + apiUrl: string, + jwt: string, + installationId: number, + repositories: string[], +): Promise { + const body: Record = {}; + if (repositories.length > 0) { + body.repositories = repositories; + } + const resp = await fetchFn( + `${apiUrl}/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: ghHeaders(jwt), + body: JSON.stringify(body), + }, + ); + if (!resp.ok) { + const text = await resp.text(); + throw new Error( + `failed to mint installation token (HTTP ${resp.status}): ${text}`, + ); + } + const data = (await resp.json()) as { token?: string }; + if (!data.token || data.token.length === 0) { + throw new Error("installation token response contained no token"); + } + return data.token; +} + +function requireEnv(env: NodeJS.ProcessEnv, key: string): string { + const value = env[key]; + if (!value || value.length === 0) { + throw new Error(`required env var ${key} is missing or empty`); + } + return value; +} + +/** Normalize an optional API base URL, defaulting to GHEC and stripping any + * trailing slashes. */ +function normalizeApiUrl(raw: string | undefined): string { + return (raw && raw.length > 0 ? raw : DEFAULT_API_URL).replace(/\/+$/, ""); +} + +/** + * Options that steer the mint/revoke flows. These are **compiler-owned, + * non-secret** inputs passed as argv flags — deliberately NOT read from + * `process.env`. ADO injects every pipeline variable into a step's env, so an + * env-sourced knob (e.g. the output-variable name) could be silently shadowed + * by a same-named pipeline variable and, in the worst case, redirect the minted + * token. Argv comes only from the compiler-authored step script, so it cannot + * be shadowed. Secret material (`GH_APP_PRIVATE_KEY`, and the minted + * `GH_APP_TOKEN` for revoke) stays in env as `$(secret)` macros so ADO masks it + * and it never appears in the step's command line. + */ +export interface CliArgs { + appId?: string; + owner?: string; + outputVar?: string; + repositories?: string; + apiUrl?: string; +} + +/** + * Parse the `--flag value` argv (after any leading `revoke` subcommand) into a + * flat `CliArgs`. Unknown flags are ignored so the compiler can add new ones + * without breaking an older bundle. Repeated flags are last-write-wins. + * + * Forward-compatibility: a token that looks like a flag (`--…`) is never + * consumed as the *value* of a preceding flag. This keeps parsing aligned even + * if a future compiler emits a value-less boolean flag (e.g. `--debug`) — the + * boolean is skipped by itself rather than swallowing the next real flag. + */ +export function parseArgs(argv: string[]): CliArgs { + const out: CliArgs = {}; + let i = 0; + while (i < argv.length) { + const flag = argv[i]; + const next = argv[i + 1]; + // A `--…` token is a flag, not a value: treat the current flag as valueless + // and advance by one so the next flag stays aligned. + const value = next !== undefined && !next.startsWith("--") ? next : undefined; + switch (flag) { + case "--app-id": + if (value !== undefined) out.appId = value; + break; + case "--owner": + if (value !== undefined) out.owner = value; + break; + case "--output-var": + if (value !== undefined) out.outputVar = value; + break; + case "--repositories": + if (value !== undefined) out.repositories = value; + break; + case "--api-url": + if (value !== undefined) out.apiUrl = value; + break; + default: + break; + } + i += value !== undefined ? 2 : 1; + } + return out; +} + +function requireArg(value: string | undefined, flag: string): string { + if (!value || value.length === 0) { + throw new Error(`required argument ${flag} is missing or empty`); + } + return value; +} + +/** + * Revoke (delete) the installation access token via + * `DELETE /installation/token`, authenticated with the token itself. + * + * Fully best-effort: a missing token or a failed request is logged as a + * warning and still returns 0, so token revocation can never fail the build + * (the caller also marks the step `continueOnError`). Reads the minted token + * from `GH_APP_TOKEN` (the masked same-job variable set by the mint step). + */ +export async function revoke( + args: CliArgs, + env: NodeJS.ProcessEnv = process.env, + fetchFn: FetchLike = fetch as unknown as FetchLike, +): Promise { + const token = env.GH_APP_TOKEN; + if (!token || token.length === 0) { + logWarning("[github-app-token] no token to revoke (GH_APP_TOKEN empty)"); + return 0; + } + const apiUrl = normalizeApiUrl(args.apiUrl); + try { + const resp = await fetchFn(`${apiUrl}/installation/token`, { + method: "DELETE", + headers: ghHeaders(token), + }); + if (resp.ok) { + logInfo("[github-app-token] revoked installation token"); + } else { + logWarning( + `[github-app-token] revoke returned HTTP ${resp.status} (ignored)`, + ); + } + } catch (err) { + logWarning( + `[github-app-token] revoke failed (ignored): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + return 0; +} + +export async function main( + args: CliArgs, + env: NodeJS.ProcessEnv = process.env, + fetchFn: FetchLike = fetch as unknown as FetchLike, +): Promise { + try { + // Non-secret, compiler-owned inputs come from argv (immune to pipeline-var + // shadowing); only the private key is read from the masked env. + const appId = requireArg(args.appId, "--app-id"); + const owner = requireArg(args.owner, "--owner"); + const privateKey = requireEnv(env, "GH_APP_PRIVATE_KEY"); + const repositories = parseRepositories(args.repositories); + const apiUrl = normalizeApiUrl(args.apiUrl); + const outputVar = + args.outputVar && args.outputVar.length > 0 + ? args.outputVar + : DEFAULT_OUTPUT_VAR; + + const jwt = buildAppJwt(appId, privateKey); + const installationId = await resolveInstallationId( + fetchFn, + apiUrl, + jwt, + owner, + ); + const token = await mintInstallationToken( + fetchFn, + apiUrl, + jwt, + installationId, + repositories, + ); + + // Mask + expose to the same-job Copilot step. Emitting the secret BEFORE + // any log line that could contain it keeps ADO's scrubber ahead of leaks. + setSecretVar(outputVar, token); + logInfo( + `[github-app-token] minted installation token for owner '${owner}' ` + + `(installation ${installationId}, ${ + repositories.length > 0 + ? `${repositories.length} repo(s)` + : "all repos" + }) -> $(${outputVar})`, + ); + return 0; + } catch (err) { + logError( + `[github-app-token] ${err instanceof Error ? err.message : String(err)}`, + ); + return 1; + } +} + +// CLI entry guard: only run when invoked directly (not when imported by tests). +// A leading `revoke` argument switches to token-revocation mode; otherwise the +// default mint flow runs. Compiler-owned inputs are argv flags; secrets stay in +// env. Uses argv comparison rather than a top-level await so the bundle stays +// CJS. +if ( + typeof process !== "undefined" && + process.argv[1] && + /github-app-token(\/index)?\.js$/.test(process.argv[1]) +) { + const rest = process.argv.slice(2); + const isRevoke = rest[0] === "revoke"; + const args = parseArgs(isRevoke ? rest.slice(1) : rest); + const run = isRevoke ? revoke(args) : main(args); + run.then((rc) => process.exit(rc)); +} diff --git a/scripts/ado-script/src/shared/vso-logger.ts b/scripts/ado-script/src/shared/vso-logger.ts index dd0dec38..97223535 100644 --- a/scripts/ado-script/src/shared/vso-logger.ts +++ b/scripts/ado-script/src/shared/vso-logger.ts @@ -71,6 +71,21 @@ export function setVar(name: string, value: string): void { emit(`##vso[task.setvariable variable=${safeName}]${safeValue}`); } +/** + * Set a **masked secret** same-job pipeline variable + * (`task.setvariable variable=…;issecret=true`). ADO registers the value as a + * secret so it is scrubbed from subsequent log output. Like {@link setVar}, the + * variable is visible to later steps in the **same job** via `$(name)` — it is + * NOT an output variable, so it does not cross job boundaries. Use this for + * minted tokens (e.g. a GitHub App installation token) that must be consumed by + * a downstream step in the same job without leaking into the log. + */ +export function setSecretVar(name: string, value: string): void { + const safeName = escapeProperty(name); + const safeValue = escapeMessage(value); + emit(`##vso[task.setvariable variable=${safeName};issecret=true]${safeValue}`); +} + export function addBuildTag(tag: string): void { emit(`##vso[build.addbuildtag]${escapeMessage(tag)}`); } diff --git a/src/compile/ado_bundle.rs b/src/compile/ado_bundle.rs index c6141fc3..3fe582bc 100644 --- a/src/compile/ado_bundle.rs +++ b/src/compile/ado_bundle.rs @@ -53,6 +53,10 @@ pub enum Bundle { ExecContextRepo, ApprovalSummary, Conclusion, + /// GitHub App installation-token minter/revoker (issue #1316). Runs before + /// the Copilot invocation in the Agent and Detection jobs. It authenticates + /// to the **GitHub** API (not ADO REST), so it needs no ADO bearer. + GithubAppToken, } /// The auth contract a bundle requires from the step that invokes it. @@ -130,6 +134,7 @@ impl Bundle { Bundle::ExecContextRepo, Bundle::ApprovalSummary, Bundle::Conclusion, + Bundle::GithubAppToken, ]; /// The bundle's unpacked on-disk path inside the runtime VM. The Conclusion @@ -154,6 +159,7 @@ impl Bundle { Bundle::ExecContextRepo => paths::EXEC_CONTEXT_REPO_PATH, Bundle::ApprovalSummary => paths::APPROVAL_SUMMARY_PATH, Bundle::Conclusion => paths::CONCLUSION_PATH, + Bundle::GithubAppToken => paths::GITHUB_APP_TOKEN_PATH, } } @@ -175,7 +181,10 @@ impl Bundle { Bundle::Import | Bundle::ExecContextManual | Bundle::ExecContextRepo - | Bundle::ApprovalSummary => BundleAuth::None, + | Bundle::ApprovalSummary + // Authenticates to the GitHub API with its own App JWT / minted + // token, not the ADO bearer. + | Bundle::GithubAppToken => BundleAuth::None, } } } diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index cc700845..6c5f04b6 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -911,7 +911,23 @@ fn build_agent_job( steps.push(Step::Bash(verify_mcp_backends_step())); } - // 18. Run copilot (AWF network isolated) — the big one + // 18. Run copilot (AWF network isolated) — the big one. + // When GitHub App auth is configured, mint the installation token + // immediately before the Copilot run; `copilot_env` sources + // `GITHUB_TOKEN` from the masked same-job `GITHUB_APP_TOKEN` the mint + // step sets. Never runs for SafeOutputs/user steps. + // + // The ado-script bundle is staged by the ado-script extension's + // agent-prepare steps: `github_app_token_active` is OR'd into that + // extension's Agent-job download predicate (mirroring + // `safe_outputs_summary_active`), so the bundle is guaranteed present by + // the time we reach this step — no need to inspect emitted steps or + // re-download here. + if let Some(app_token) = front_matter.engine.github_app_token() { + steps.push(super::extensions::ado_script::github_app_token_step_typed( + app_token, + )?); + } steps.push(Step::Bash(run_agent_step( &cfg.allowed_domains, &cfg.awf_mounts, @@ -921,6 +937,17 @@ fn build_agent_job( &cfg.byom_exclude_keys, )?)); + // 18a. Revoke the GitHub App token (best-effort, always) once the Copilot + // run has returned, so the minted installation token does not remain + // valid for its full lifetime. Skipped when `skip-token-revocation`. + if let Some(app_token) = front_matter.engine.github_app_token() + && !app_token.skip_token_revocation + { + steps.push(super::extensions::ado_script::github_app_token_revoke_step_typed( + app_token, + )?); + } + // 19. Collect safe outputs from AWF container steps.push(Step::Bash(collect_safe_outputs_step())); @@ -1046,6 +1073,17 @@ fn agent_job_variables_hoist( /// (see `AdoScriptExtension::build_agent_conditions` for today's /// only contributor — synth-PR-skip, PR-filter gate, pipeline-filter /// gate, and user `expression:` escape hatches). +/// Whether the Detection job must stage the `ado-script` bundle. The Detection +/// job has no extension-prepare phase (unlike the Agent job, whose bundle +/// download is contributed by `AdoScriptExtension`), so it stages the bundle +/// itself — but gated on this single predicate so exactly one download is +/// emitted. Today only the GitHub App token step needs it; future +/// detection-only bundle consumers should `||` their own condition in here +/// rather than adding a second `install_and_download_steps_typed` call. +fn detection_job_needs_ado_script_bundle(front_matter: &FrontMatter) -> bool { + front_matter.engine.github_app_token().is_some() +} + fn build_detection_job( front_matter: &FrontMatter, cfg: &StandaloneCtx, @@ -1105,6 +1143,25 @@ fn build_detection_job( )?)); // Setup compiler steps.push(Step::Bash(setup_compiler_step())); + // When GitHub App auth is configured, mint the installation token + // immediately before the threat-analysis Copilot run. Unlike the Agent job + // (whose bundle download is staged by the ado-script extension's + // agent-prepare phase, gated on `github_app_token_active`), the Detection + // job has no extension-prepare phase to piggyback on, so it stages the + // bundle self-contained — but exactly once, gated on the single + // `detection_job_needs_ado_script_bundle` predicate below so future + // detection-only bundle consumers OR into one download rather than adding a + // second (mirroring the Agent-job predicate in `AdoScriptExtension`). + if detection_job_needs_ado_script_bundle(front_matter) { + steps.extend(super::extensions::ado_script::install_and_download_steps_typed( + front_matter.supply_chain(), + )); + } + if let Some(app_token) = front_matter.engine.github_app_token() { + steps.push(super::extensions::ado_script::github_app_token_step_typed( + app_token, + )?); + } // Run threat analysis steps.push(Step::Bash(run_threat_analysis_step( &cfg.allowed_domains, @@ -1112,7 +1169,16 @@ fn build_detection_job( &cfg.engine_run_detection, &cfg.byom_exclude_keys, &cfg.detection_provider_env, + crate::engine::github_token_source_var(&front_matter.engine), )?)); + // Revoke the GitHub App token (best-effort, always) after threat analysis. + if let Some(app_token) = front_matter.engine.github_app_token() + && !app_token.skip_token_revocation + { + steps.push(super::extensions::ado_script::github_app_token_revoke_step_typed( + app_token, + )?); + } // Prepare analyzed outputs steps.push(Step::Bash(prepare_analyzed_outputs_step())); // Evaluate threat analysis — DECLARES TYPED OUTPUT @@ -2776,6 +2842,7 @@ fn run_threat_analysis_step( engine_run_detection: &str, byom_exclude_keys: &[String], detection_provider_env: &[(String, String)], + github_token_var: &str, ) -> Result { let api_proxy_block = awf_api_proxy_flags(byom_exclude_keys); let script = format!( @@ -2808,7 +2875,7 @@ fn run_threat_analysis_step( // legacy copilot_env output of `GITHUB_READ_ONLY: 1`, not `'1'`). use super::ir::env::EnvValue; step = step - .with_env("GITHUB_TOKEN", EnvValue::pipeline_var("GITHUB_TOKEN")) + .with_env("GITHUB_TOKEN", EnvValue::pipeline_var(github_token_var)) .with_env( "GITHUB_READ_ONLY", EnvValue::RawYamlScalar(serde_yaml::Value::Number(1.into())), diff --git a/src/compile/common.rs b/src/compile/common.rs index f9ae120a..32100e32 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -4843,7 +4843,7 @@ safe-outputs: fn test_model_name_rejects_single_quote() { let mut fm = minimal_front_matter(); fm.engine = - crate::compile::types::EngineConfig::Full(crate::compile::types::EngineOptions { + crate::compile::types::EngineConfig::Full(Box::new(crate::compile::types::EngineOptions { id: Some("copilot".to_string()), model: Some("model' && echo pwned".to_string()), version: None, @@ -4853,7 +4853,8 @@ safe-outputs: env: None, command: None, timeout_minutes: None, - }); + github_app_token: None, + })); let result = engine_args_for(&fm); assert!(result.is_err()); assert!( @@ -4868,7 +4869,7 @@ safe-outputs: fn test_model_name_rejects_space() { let mut fm = minimal_front_matter(); fm.engine = - crate::compile::types::EngineConfig::Full(crate::compile::types::EngineOptions { + crate::compile::types::EngineConfig::Full(Box::new(crate::compile::types::EngineOptions { id: Some("copilot".to_string()), model: Some("model && curl evil.com".to_string()), version: None, @@ -4878,7 +4879,8 @@ safe-outputs: env: None, command: None, timeout_minutes: None, - }); + github_app_token: None, + })); let result = engine_args_for(&fm); assert!(result.is_err()); } @@ -4893,7 +4895,7 @@ safe-outputs: ] { let mut fm = minimal_front_matter(); fm.engine = - crate::compile::types::EngineConfig::Full(crate::compile::types::EngineOptions { + crate::compile::types::EngineConfig::Full(Box::new(crate::compile::types::EngineOptions { id: Some("copilot".to_string()), model: Some(name.to_string()), version: None, @@ -4903,7 +4905,8 @@ safe-outputs: env: None, command: None, timeout_minutes: None, - }); + github_app_token: None, + })); let result = engine_args_for(&fm); assert!(result.is_ok(), "Model name '{}' should be valid", name); } diff --git a/src/compile/extensions/ado_script.rs b/src/compile/extensions/ado_script.rs index 2acb433d..eecd1f98 100644 --- a/src/compile/extensions/ado_script.rs +++ b/src/compile/extensions/ado_script.rs @@ -94,6 +94,12 @@ pub(crate) const APPROVAL_SUMMARY_PATH: &str = /// that job's shell body and by `Bundle::Conclusion.path()` so the two copies /// cannot diverge. pub(crate) const CONCLUSION_PATH: &str = "/tmp/ado-aw-scripts/ado-script/conclusion.js"; +/// Path to the github-app-token bundle inside the unpacked `ado-script.zip`. +/// Runs immediately before the Copilot invocation in the Agent and Detection +/// jobs (issue #1316) to mint a GitHub App installation token and expose it as +/// a masked same-job `GITHUB_APP_TOKEN` variable. +pub(crate) const GITHUB_APP_TOKEN_PATH: &str = + "/tmp/ado-aw-scripts/ado-script/github-app-token.js"; const RELEASE_BASE_URL: &str = "https://github.com/githubnext/ado-aw/releases/download"; /// Single always-on extension that owns all `ado-script` bundle wiring. @@ -159,6 +165,15 @@ pub struct AdoScriptExtension { /// fire so that `approval-summary.js` is present for the /// end-of-job render step (emitted by `build_agent_job`). pub safe_outputs_summary_active: bool, + /// Whether GitHub App-backed Copilot auth is configured + /// (`engine.github-app-token`, issue #1316). When true the Agent-job + /// install/download must fire so that `github-app-token.js` is present for + /// the mint (and revoke) steps that `build_agent_job` emits immediately + /// around the Copilot run. Mirrors `safe_outputs_summary_active`: the + /// consuming steps are emitted by `build_agent_job`, not this extension, so + /// the flag drives the shared bundle download — the builder never has to + /// inspect emitted steps to decide whether to download. + pub github_app_token_active: bool, /// PR trigger config required to build `PR_SYNTH_SPEC`. `Some(_)` /// is the single source of truth for "synthetic-from-ci path is /// active for this agent" — `is_some()` replaces what used to be a @@ -479,6 +494,121 @@ fn resolver_step_typed() -> Step { ) } +/// The GitHub App token-mint step (issue #1316). Runs immediately before the +/// Copilot invocation in the Agent and Detection jobs to mint a GitHub App +/// installation access token via the `github-app-token` ado-script bundle and +/// expose it as a masked, same-job `GITHUB_APP_TOKEN` variable, which +/// `copilot_env` sources `GITHUB_TOKEN` from. +/// +/// ## Why non-secret inputs are argv flags, not env vars +/// +/// ADO injects **every pipeline variable** into a step's process env, so an +/// env-sourced knob can be silently shadowed by a same-named pipeline variable +/// — in the worst case redirecting the minted token to an attacker-chosen +/// variable name. Argv comes only from this compiler-authored script, so it +/// cannot be shadowed. All non-secret inputs (`--app-id`, `--owner`, +/// `--output-var`, `--repositories`, `--api-url`) are therefore passed as +/// single-quoted argv flags. Values are single-quoted (with `'\''` escaping) so +/// a value containing shell metacharacters cannot break out or trigger command +/// substitution. +/// +/// The **private key** stays in the masked env (`GH_APP_PRIVATE_KEY: +/// $(secret)`): a secret must never appear on a command line (it would show in +/// process listings and the rendered step script), and ADO only maps a secret +/// variable into env on explicit reference. Its variable name is the +/// `private-key` override or the compiler-owned default `GITHUB_APP_PRIVATE_KEY`. +/// +/// The step runs OUTSIDE the AWF sandbox and reaches `api.github.com` (or +/// `--api-url`) over the build agent pool's normal network — no AWF allowlist +/// entry is required. +pub fn github_app_token_step_typed( + cfg: &crate::compile::types::GithubAppTokenConfig, +) -> Result { + cfg.validate()?; + // The App ID is a non-secret literal (numeric App ID or alphanumeric client + // ID); single-quote it so any character is passed through as one argv token. + let mut args: Vec = vec![ + format!("--app-id {}", sh_single_quote(&cfg.app_id)), + format!("--owner {}", sh_single_quote(&cfg.owner)), + // Pin the output variable name (a compiler constant) as argv so no + // pipeline variable can redirect the minted token. + format!( + "--output-var {}", + sh_single_quote(crate::engine::GITHUB_APP_TOKEN_VAR) + ), + ]; + if !cfg.repositories.is_empty() { + args.push(format!( + "--repositories {}", + sh_single_quote(&cfg.repositories.join(" ")) + )); + } + if let Some(api_url) = &cfg.api_url { + args.push(format!("--api-url {}", sh_single_quote(api_url))); + } + let script = format!( + "set -eo pipefail\nnode '{GITHUB_APP_TOKEN_PATH}' {}\n", + args.join(" ") + ); + let step = BashStep::new("Mint GitHub App token (Copilot engine auth)", script) + .with_condition(Condition::Succeeded) + // Only the secret rides in env — masked, never on the command line. Its + // variable name is the `private-key` override or the default + // `GITHUB_APP_PRIVATE_KEY` (see `GithubAppTokenConfig::private_key_var`). + .with_env( + "GH_APP_PRIVATE_KEY", + EnvValue::secret(cfg.private_key_var().to_string()), + ); + Ok(Step::Bash(step)) +} + +/// Single-quote a value for safe embedding in a bash command line, escaping any +/// embedded single quotes as `'\''`. Single quotes suppress all shell +/// expansion (`$(...)`, backticks, `${...}`, word-splitting), so a value with +/// metacharacters is passed through verbatim as one argv token. +fn sh_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// The GitHub App token **revocation** step (issue #1316). Runs after the +/// Copilot invocation in the Agent and Detection jobs (unless +/// `skip-token-revocation` is set) to delete the minted installation token +/// (`DELETE /installation/token`) so it does not remain valid for its full +/// ~1h lifetime — matching `actions/create-github-app-token`'s default. +/// +/// Best-effort: it runs with `condition: always()` (so it fires even when the +/// Copilot run failed) and `continueOnError` (revocation failure never fails +/// the build). The minted token is read from the masked same-job +/// `GITHUB_APP_TOKEN` variable (via the `GH_APP_TOKEN` secret env); the API base +/// URL, if any, is an argv flag (see `github_app_token_step_typed` for why +/// non-secret inputs are argv, not env). +pub fn github_app_token_revoke_step_typed( + cfg: &crate::compile::types::GithubAppTokenConfig, +) -> Result { + // Validate for symmetry with the mint step, so neither function silently + // assumes the other ran first (e.g. if a future caller emits revoke alone). + cfg.validate()?; + // No `set -eo pipefail` here (unlike the mint step) is intentional: this is + // a best-effort cleanup. The bundle's `revoke` mode always exits 0 (it + // downgrades every failure to a warning), and the step is `continueOnError`, + // so aborting the shell early on a non-zero would neither help nor change + // the outcome — it would only risk turning a benign revoke hiccup into a + // timeline error. + let api_url_arg = match &cfg.api_url { + Some(api_url) => format!(" --api-url {}", sh_single_quote(api_url)), + None => String::new(), + }; + let script = format!("node '{GITHUB_APP_TOKEN_PATH}' revoke{api_url_arg}\n"); + let step = BashStep::new("Revoke GitHub App token", script) + .with_condition(Condition::Always) + .with_continue_on_error(true) + .with_env( + "GH_APP_TOKEN", + EnvValue::secret(crate::engine::GITHUB_APP_TOKEN_VAR), + ); + Ok(Step::Bash(step)) +} + /// The synthetic-PR-context step that runs in the Setup job before /// `prGate`. Declares the PR outputs so downstream consumers can /// reference them via [`crate::compile::ir::output::OutputRef`]. @@ -661,6 +791,7 @@ impl CompilerExtension for AdoScriptExtension { || self.exec_context_pr_checks_active || self.exec_context_repo_active || self.safe_outputs_summary_active + || self.github_app_token_active { agent_prepare_steps.extend(install_and_download_steps_typed(self.supply_chain.as_ref())); if import_active { @@ -865,6 +996,7 @@ mod tests { exec_context_pr_checks_active: false, exec_context_repo_active: false, safe_outputs_summary_active: false, + github_app_token_active: false, pr_trigger_for_synth: None, supply_chain: None, } @@ -935,6 +1067,7 @@ mod tests { exec_context_pr_checks_active: false, exec_context_repo_active: false, safe_outputs_summary_active: false, + github_app_token_active: false, pr_trigger_for_synth: Some(PrTriggerConfig { branches: Some(BranchFilter { include: vec!["main".into()], @@ -992,6 +1125,7 @@ mod tests { exec_context_pr_checks_active: false, exec_context_repo_active: false, safe_outputs_summary_active: false, + github_app_token_active: false, pr_trigger_for_synth: Some(PrTriggerConfig { branches: Some(BranchFilter { include: vec!["main".into()], @@ -1051,6 +1185,229 @@ mod tests { } } + #[test] + fn github_app_token_step_renders_node_invocation_and_env() { + use crate::compile::types::GithubAppTokenConfig; + let cfg = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: Some("GH_APP_KEY".to_string()), + owner: "octo-org".to_string(), + repositories: vec!["octo-repo".to_string(), "other-repo".to_string()], + api_url: None, + skip_token_revocation: false, + }; + let Step::Bash(step) = github_app_token_step_typed(&cfg).unwrap() else { + panic!("expected a bash step"); + }; + assert_eq!(step.display_name, "Mint GitHub App token (Copilot engine auth)"); + assert!( + step.script + .contains("node '/tmp/ado-aw-scripts/ado-script/github-app-token.js'"), + "script must invoke the bundle:\n{}", + step.script + ); + // Non-secret inputs are single-quoted argv flags (shadow-proof). The + // app-id is a literal. + assert!( + step.script.contains("--app-id '1234567'"), + "app-id must be a single-quoted literal arg:\n{}", + step.script + ); + assert!( + step.script.contains("--owner 'octo-org'"), + "owner must be a single-quoted argv flag:\n{}", + step.script + ); + assert!( + step.script.contains("--repositories 'octo-repo other-repo'"), + "repositories must be a single-quoted argv flag:\n{}", + step.script + ); + // The output variable name is pinned as argv so no pipeline variable + // can redirect the minted token. + assert!( + step.script.contains("--output-var 'GITHUB_APP_TOKEN'"), + "output-var must be pinned as an argv flag:\n{}", + step.script + ); + // No api-url configured ⇒ no --api-url flag (bundle uses its default). + assert!(!step.script.contains("--api-url")); + // Only the private key rides in env — as a masked secret, never argv. + // Here the `private-key` override names GH_APP_KEY. + assert!(matches!( + step.env.get("GH_APP_PRIVATE_KEY"), + Some(EnvValue::Secret(v)) if v == "GH_APP_KEY" + )); + // No non-secret GH_APP_* env keys (they are argv now). + assert!(!step.env.contains_key("GH_APP_ID")); + assert!(!step.env.contains_key("GH_APP_OWNER")); + assert!(!step.env.contains_key("GH_APP_REPOSITORIES")); + assert!(!step.env.contains_key("GH_APP_OUTPUT_VAR")); + assert!(!step.env.contains_key("GH_APP_API_URL")); + } + + #[test] + fn github_app_token_step_defaults_private_key_var() { + use crate::compile::types::GithubAppTokenConfig; + // private-key omitted ⇒ the masked secret env uses the compiler default. + let cfg = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: None, + owner: "octo-org".to_string(), + repositories: vec![], + api_url: None, + skip_token_revocation: false, + }; + let Step::Bash(step) = github_app_token_step_typed(&cfg).unwrap() else { + panic!("expected a bash step"); + }; + assert!(matches!( + step.env.get("GH_APP_PRIVATE_KEY"), + Some(EnvValue::Secret(v)) if v == "GITHUB_APP_PRIVATE_KEY" + )); + } + + #[test] + fn github_app_token_step_accepts_client_id_app_id() { + use crate::compile::types::GithubAppTokenConfig; + // An alphanumeric client ID is a valid literal app-id (regression guard + // against the old digits-only "is it a variable?" heuristic). + let cfg = GithubAppTokenConfig { + app_id: "Iv23liABCdef".to_string(), + private_key: None, + owner: "octo-org".to_string(), + repositories: vec![], + api_url: None, + skip_token_revocation: false, + }; + let Step::Bash(step) = github_app_token_step_typed(&cfg).unwrap() else { + panic!("expected a bash step"); + }; + assert!( + step.script.contains("--app-id 'Iv23liABCdef'"), + "client-id app-id must be a single-quoted literal, not a macro:\n{}", + step.script + ); + } + + #[test] + fn github_app_token_step_renders_literal_app_id_and_api_url() { + use crate::compile::types::GithubAppTokenConfig; + let cfg = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: Some("GH_APP_KEY".to_string()), + owner: "octo-org".to_string(), + repositories: vec![], + api_url: Some("https://ghe.example.com/api/v3".to_string()), + skip_token_revocation: false, + }; + let Step::Bash(step) = github_app_token_step_typed(&cfg).unwrap() else { + panic!("expected a bash step"); + }; + // A numeric app-id is emitted verbatim (single-quoted), not as a macro. + assert!( + step.script.contains("--app-id '1234567'"), + "literal app-id must be a single-quoted verbatim arg:\n{}", + step.script + ); + assert!(!step.script.contains("--app-id '$(")); + assert!( + step.script + .contains("--api-url 'https://ghe.example.com/api/v3'"), + "api-url must be a single-quoted argv flag:\n{}", + step.script + ); + } + + #[test] + fn github_app_token_step_omits_repositories_when_empty() { + use crate::compile::types::GithubAppTokenConfig; + let cfg = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: Some("GH_APP_KEY".to_string()), + owner: "octo-org".to_string(), + repositories: vec![], + api_url: None, + skip_token_revocation: false, + }; + let Step::Bash(step) = github_app_token_step_typed(&cfg).unwrap() else { + panic!("expected a bash step"); + }; + assert!(!step.script.contains("--repositories")); + } + + #[test] + fn github_app_token_step_rejects_invalid_config() { + use crate::compile::types::GithubAppTokenConfig; + let cfg = GithubAppTokenConfig { + app_id: "bad var".to_string(), + private_key: Some("GH_APP_KEY".to_string()), + owner: "octo-org".to_string(), + repositories: vec![], + api_url: None, + skip_token_revocation: false, + }; + assert!(github_app_token_step_typed(&cfg).is_err()); + } + + #[test] + fn github_app_token_revoke_step_renders_best_effort_delete() { + use crate::compile::ir::condition::Condition; + use crate::compile::types::GithubAppTokenConfig; + let cfg = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: Some("GH_APP_KEY".to_string()), + owner: "octo-org".to_string(), + repositories: vec![], + api_url: Some("https://ghe.example.com/api/v3".to_string()), + skip_token_revocation: false, + }; + let Step::Bash(step) = github_app_token_revoke_step_typed(&cfg).unwrap() else { + panic!("expected a bash step"); + }; + assert!( + step.script + .contains("node '/tmp/ado-aw-scripts/ado-script/github-app-token.js' revoke"), + "revoke step must invoke the bundle in revoke mode:\n{}", + step.script + ); + // The minted token is passed as a masked secret; never fails the build. + assert!(matches!( + step.env.get("GH_APP_TOKEN"), + Some(EnvValue::Secret(v)) if v == "GITHUB_APP_TOKEN" + )); + // api-url is an argv flag (non-secret), not an env var. + assert!( + step.script.contains("revoke --api-url 'https://ghe.example.com/api/v3'"), + "revoke must pass api-url as an argv flag:\n{}", + step.script + ); + assert!(!step.env.contains_key("GH_APP_API_URL")); + assert_eq!(step.condition, Some(Condition::Always)); + assert!(step.continue_on_error); + } + + #[test] + fn github_app_token_revoke_step_rejects_invalid_config() { + // Symmetry with the mint step: the revoke builder validates too, so a + // caller can't emit a revoke step from an invalid config. + use crate::compile::types::GithubAppTokenConfig; + let cfg = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: Some("GH_APP_KEY".to_string()), + owner: "octo-org".to_string(), + repositories: vec![], + api_url: Some("http://insecure.example.com/api/v3".to_string()), + skip_token_revocation: false, + }; + assert!(github_app_token_revoke_step_typed(&cfg).is_err()); + } + + #[test] + fn github_app_token_eval_path_consistent_with_download_dir() { + assert!(GITHUB_APP_TOKEN_PATH.starts_with("/tmp/ado-aw-scripts/ado-script/")); + } + #[test] fn declarations_agent_prepare_steps_emits_install_download_and_resolver_when_runtime_imports_active() { @@ -1174,6 +1531,7 @@ mod tests { exec_context_pr_checks_active: false, exec_context_repo_active: false, safe_outputs_summary_active: false, + github_app_token_active: false, pr_trigger_for_synth: Some(PrTriggerConfig { branches: Some(BranchFilter { include: vec!["main".into()], @@ -1707,6 +2065,7 @@ mod tests { exec_context_pr_checks_active: false, exec_context_repo_active: false, safe_outputs_summary_active: false, + github_app_token_active: false, pr_trigger_for_synth: Some(PrTriggerConfig { branches: Some(BranchFilter { include: vec!["main".into()], diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index d8c6c131..41c34656 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -700,6 +700,13 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { // `build_agent_job` emits. MUST use the same predicate as that // step (see `FrontMatter::has_any_safe_output_tool`). safe_outputs_summary_active: front_matter.has_any_safe_output_tool(), + // True when `engine.github-app-token` is configured — drives the + // Agent-job bundle install/download so `github-app-token.js` is + // present for the mint/revoke steps that `build_agent_job` emits + // around the Copilot run. Same loose-coupling pattern as + // `safe_outputs_summary_active`: the consuming steps live in + // `build_agent_job`, not this extension. + github_app_token_active: front_matter.engine.github_app_token().is_some(), pr_trigger_for_synth, supply_chain: front_matter.supply_chain().cloned(), } diff --git a/src/compile/ir/step.rs b/src/compile/ir/step.rs index 6f39b6b8..f2d7188d 100644 --- a/src/compile/ir/step.rs +++ b/src/compile/ir/step.rs @@ -130,6 +130,12 @@ impl BashStep { self } + /// Set `continueOnError` (best-effort steps that must never fail the build). + pub fn with_continue_on_error(mut self, yes: bool) -> Self { + self.continue_on_error = yes; + self + } + /// Add (or replace) an env-var binding. pub fn with_env(mut self, key: impl Into, value: EnvValue) -> Self { self.env.insert(key.into(), value); diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 28ef9aee..747847bb 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -177,6 +177,20 @@ async fn compile_pipeline_inner( eprintln!("Warning: {warning}"); } + // Reject engine-specific config on the wrong engine before anything else + // consumes it (gh-aw pattern: engine-gated config is a hard error, not a + // silent no-op). Runs before target dispatch / `get_engine`, so the author + // gets this precise message rather than a generic "unsupported engine". + crate::engine::validate_engine_feature_support(&front_matter.engine)?; + + // GitHub App auth (issue #1316): the compiler validates the private-key + // variable *name* but cannot verify the ADO variable is marked secret. + // Surface a non-blocking advisory so the author is reminded to store it as + // a secret. Fires only when `engine.github-app-token` is configured. + if let Some(advisory) = crate::engine::github_app_token_secrecy_advisory(&front_matter.engine) { + eprintln!("Warning: {advisory}"); + } + // Determine output path. By default use `.lock.yml` to match // gh-aw's convention for compiled-pipeline files (so they can be // marked as generated and merge=ours via `.gitattributes`). When the diff --git a/src/compile/types.rs b/src/compile/types.rs index 7a10a015..a39e6f61 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -217,7 +217,7 @@ pub enum EngineConfig { /// Engine identifier string (e.g., "copilot") Simple(String), /// Full engine configuration object - Full(EngineOptions), + Full(Box), } impl Default for EngineConfig { @@ -299,6 +299,16 @@ impl EngineConfig { EngineConfig::Full(opts) => opts.command.as_deref(), } } + + /// Get the GitHub App token configuration, if specified. + /// Returns `None` when the engine uses the default `$(GITHUB_TOKEN)` + /// pipeline-variable source. + pub fn github_app_token(&self) -> Option<&GithubAppTokenConfig> { + match self { + EngineConfig::Simple(_) => None, + EngineConfig::Full(opts) => opts.github_app_token.as_ref(), + } + } } impl SanitizeConfigTrait for EngineConfig { @@ -339,6 +349,192 @@ pub struct EngineOptions { /// Workflow timeout in minutes #[serde(default, rename = "timeout-minutes")] pub timeout_minutes: Option, + /// GitHub App-backed Copilot engine authentication (Copilot only). + /// + /// When set, the compiler emits a token-mint step (the + /// `github-app-token` ado-script bundle) immediately before the Copilot + /// invocation in the Agent and Detection jobs. The minted GitHub App + /// installation token is wired into `GITHUB_TOKEN` for the Copilot engine + /// env only — never SafeOutputs, user steps, ManualReview, Teardown, or + /// Conclusion. Absent ⇒ `GITHUB_TOKEN` is sourced from the + /// `$(GITHUB_TOKEN)` pipeline variable as before. + #[serde(default, rename = "github-app-token")] + #[sanitize_config(skip)] + pub github_app_token: Option, +} + +/// GitHub App-backed Copilot engine authentication configuration. +/// +/// Mirrors gh-aw's `create-github-app-token` model, adapted to Azure DevOps. +/// The **App ID** (`app-id`) is a literal, non-secret value (a numeric App ID +/// or an alphanumeric client ID) — like `owner`, it is written verbatim. Only +/// the **private key** is secret: `private-key` names an ADO **secret** pipeline +/// variable (set via `ado-aw secrets set`), defaulting to +/// `GITHUB_APP_PRIVATE_KEY`, so the key material never appears in the source or +/// the generated lock. +/// +/// ```yaml +/// engine: +/// id: copilot +/// github-app-token: +/// app-id: 1234567 # literal App ID or client ID (required) +/// owner: octo-org # installation owner (org or user) +/// repositories: [octo-repo] # optional; scopes the installation token +/// # private-key: MY_SECRET # optional; defaults to GITHUB_APP_PRIVATE_KEY +/// ``` +#[derive(Debug, Deserialize, Clone, SanitizeConfig)] +pub struct GithubAppTokenConfig { + /// The GitHub App ID — a **literal** value, either a numeric App ID + /// (e.g. `1234567`, quoted or unquoted) or an alphanumeric client ID + /// (e.g. `Iv23liABC…`). The App ID is not secret (it is visible in the + /// App's settings and is the JWT `iss`), so it is plain per-app config like + /// `owner` — it is emitted verbatim, never indirected through a variable. + #[serde(rename = "app-id", deserialize_with = "de_string_or_number")] + pub app_id: String, + /// Optional name of the ADO **secret** pipeline variable holding the GitHub + /// App private key (PEM). Defaults to + /// [`DEFAULT_GITHUB_APP_PRIVATE_KEY_VAR`] when omitted — the compiler owns + /// the variable name, exactly like `GITHUB_TOKEN`, so the common case just + /// runs `ado-aw secrets set GITHUB_APP_PRIVATE_KEY …`. Set this only to + /// point at a differently-named secret. The key material is never inlined. + #[serde(default, rename = "private-key")] + pub private_key: Option, + /// GitHub installation owner (organization or user login) the App is + /// installed on. + pub owner: String, + /// Optional list of repository names (owner-relative) the installation + /// token should be scoped to. Empty ⇒ token spans all repositories the + /// installation grants. + #[serde(default)] + pub repositories: Vec, + /// Optional GitHub API base URL. Defaults to `https://api.github.com` + /// (GHEC). For GitHub Enterprise Server, set the `/api/v3` base URL + /// (e.g. `https://ghe.example.com/api/v3`). Must be an `https://` URL. + #[serde(default, rename = "api-url")] + pub api_url: Option, + /// When true, skip revoking the installation token after the Copilot run. + /// By default (false) the compiler emits a best-effort post-run step that + /// deletes the token (`DELETE /installation/token`) so it does not remain + /// valid for its full ~1h lifetime. + #[serde(default, rename = "skip-token-revocation")] + pub skip_token_revocation: bool, +} + +/// Default name of the ADO secret pipeline variable holding the GitHub App +/// private key when `engine.github-app-token.private-key` is omitted. The +/// compiler owns this name (mirroring the fixed `GITHUB_TOKEN` contract) so the +/// common case needs no `private-key` line — set the value with +/// `ado-aw secrets set GITHUB_APP_PRIVATE_KEY …`. +pub const DEFAULT_GITHUB_APP_PRIVATE_KEY_VAR: &str = "GITHUB_APP_PRIVATE_KEY"; + +/// Deserialize a scalar that may be a YAML string **or** an integer into a +/// `String`. Used for `github-app-token.app-id` so an unquoted numeric App ID +/// (`app-id: 1234567`) is accepted alongside a quoted string or client ID. +fn de_string_or_number<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + struct StringOrNumber; + impl serde::de::Visitor<'_> for StringOrNumber { + type Value = String; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a GitHub App ID (numeric) or client ID (string)") + } + fn visit_str(self, v: &str) -> Result { + Ok(v.to_string()) + } + fn visit_string(self, v: String) -> Result { + Ok(v) + } + fn visit_u64(self, v: u64) -> Result { + Ok(v.to_string()) + } + fn visit_i64(self, v: i64) -> Result { + Ok(v.to_string()) + } + } + deserializer.deserialize_any(StringOrNumber) +} + +impl GithubAppTokenConfig { + /// The ADO secret pipeline-variable name that holds the private key — the + /// `private-key` override if set, else [`DEFAULT_GITHUB_APP_PRIVATE_KEY_VAR`]. + pub fn private_key_var(&self) -> &str { + self.private_key + .as_deref() + .unwrap_or(DEFAULT_GITHUB_APP_PRIVATE_KEY_VAR) + } + + /// Validate the literal App ID, the optional `private-key` override variable + /// name, the GitHub owner/repository name segments, and the optional API + /// URL. `app-id` must be a non-empty `[A-Za-z0-9._-]` literal (covers + /// numeric App IDs and alphanumeric/`Iv1.`-style client IDs); + /// `private-key` (when set) must be a valid env-var name; `owner` and each + /// `repositories` entry must be a single safe path segment; `api-url` (when + /// set) must be an `https://` URL with a host. + pub fn validate(&self) -> anyhow::Result<()> { + use crate::validate::{is_safe_path_segment, is_valid_env_var_name}; + if self.app_id.is_empty() + || !self.app_id.starts_with(|c: char| c.is_ascii_alphanumeric()) + || !self + .app_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + anyhow::bail!( + "engine.github-app-token.app-id '{}' must be a non-empty GitHub App ID \ + (numeric, e.g. 1234567) or client ID (e.g. Iv23liABC): it must start with \ + an alphanumeric character and contain only [A-Za-z0-9._-]. It is a literal \ + value, not a variable name (a leading '-', e.g. a negative number, is invalid).", + self.app_id + ); + } + if let Some(private_key) = &self.private_key + && !is_valid_env_var_name(private_key) + { + anyhow::bail!( + "engine.github-app-token.private-key '{}' must be an ADO variable name \ + matching [A-Za-z_][A-Za-z0-9_]* (it names the secret variable holding the \ + PEM; omit it to use the default '{}').", + private_key, + DEFAULT_GITHUB_APP_PRIVATE_KEY_VAR + ); + } + if !is_safe_path_segment(&self.owner) { + anyhow::bail!( + "engine.github-app-token.owner '{}' is not a valid GitHub owner name \ + (allowed: [A-Za-z0-9._-], no '/', no leading '.').", + self.owner + ); + } + for repo in &self.repositories { + if !is_safe_path_segment(repo) { + anyhow::bail!( + "engine.github-app-token.repositories entry '{}' is not a valid \ + GitHub repository name (allowed: [A-Za-z0-9._-], no '/', no \ + leading '.').", + repo + ); + } + } + if let Some(api_url) = &self.api_url { + let parsed = url::Url::parse(api_url).map_err(|e| { + anyhow::anyhow!( + "engine.github-app-token.api-url '{}' is not a valid URL: {}", + api_url, + e + ) + })?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + anyhow::bail!( + "engine.github-app-token.api-url '{}' must be an https:// URL with a host \ + (e.g. https://ghe.example.com/api/v3).", + api_url + ); + } + } + Ok(()) + } } /// Tools configuration for the agent @@ -2409,7 +2605,7 @@ mod tests { fn test_engine_config_full_object_partial_fields() { let yaml = "timeout-minutes: 10"; let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); - let ec = EngineConfig::Full(opts); + let ec = EngineConfig::Full(Box::new(opts)); // id defaults to "copilot" when not specified assert_eq!(ec.engine_id(), "copilot"); // model is None when not specified (engine impl decides default) @@ -2461,7 +2657,7 @@ command: /usr/local/bin/copilot timeout-minutes: 60 "#; let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); - let ec = EngineConfig::Full(opts); + let ec = EngineConfig::Full(Box::new(opts)); assert_eq!(ec.engine_id(), "copilot"); assert_eq!(ec.model(), Some("gpt-5")); assert_eq!(ec.version(), Some("0.0.422")); @@ -2475,6 +2671,209 @@ timeout-minutes: 60 assert_eq!(env.get("AWS_REGION").unwrap(), "us-west-2"); } + // ─── GithubAppTokenConfig ──────────────────────────────────────────── + + #[test] + fn test_engine_github_app_token_deserialized() { + let yaml = r#" +id: copilot +github-app-token: + app-id: "1234567" + private-key: GH_APP_KEY + owner: octo-org + repositories: [octo-repo, other-repo] +"#; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let ec = EngineConfig::Full(Box::new(opts)); + let gat = ec.github_app_token().expect("github-app-token present"); + assert_eq!(gat.app_id, "1234567"); + // Explicit private-key override. + assert_eq!(gat.private_key.as_deref(), Some("GH_APP_KEY")); + assert_eq!(gat.private_key_var(), "GH_APP_KEY"); + assert_eq!(gat.owner, "octo-org"); + assert_eq!(gat.repositories, vec!["octo-repo", "other-repo"]); + assert!(gat.api_url.is_none()); + assert!(!gat.skip_token_revocation); + gat.validate().expect("valid config passes validation"); + } + + #[test] + fn test_engine_github_app_token_defaults_private_key_var() { + // private-key omitted ⇒ default compiler-owned secret variable name. + let yaml = r#" +id: copilot +github-app-token: + app-id: 1234567 + owner: octo-org +"#; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let gat = EngineConfig::Full(Box::new(opts)) + .github_app_token() + .unwrap() + .clone(); + assert!(gat.private_key.is_none()); + assert_eq!(gat.private_key_var(), "GITHUB_APP_PRIVATE_KEY"); + gat.validate().expect("default private-key is valid"); + } + + #[test] + fn test_engine_github_app_token_unquoted_numeric_and_api_url() { + // Unquoted numeric app-id + api-url + skip-token-revocation. + let yaml = r#" +id: copilot +github-app-token: + app-id: 1234567 + owner: octo-org + api-url: https://ghe.example.com/api/v3 + skip-token-revocation: true +"#; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let gat = EngineConfig::Full(Box::new(opts)) + .github_app_token() + .unwrap() + .clone(); + assert_eq!(gat.app_id, "1234567"); + assert_eq!(gat.api_url.as_deref(), Some("https://ghe.example.com/api/v3")); + assert!(gat.skip_token_revocation); + gat.validate().expect("numeric app-id + https api-url is valid"); + } + + #[test] + fn test_engine_github_app_token_accepts_client_id() { + // Alphanumeric client ID is a valid literal app-id (regression guard + // against the removed digits-only heuristic). + let yaml = r#" +id: copilot +github-app-token: + app-id: Iv23liABCdef + owner: octo-org +"#; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let gat = EngineConfig::Full(Box::new(opts)) + .github_app_token() + .unwrap() + .clone(); + assert_eq!(gat.app_id, "Iv23liABCdef"); + gat.validate().expect("client-id app-id is valid"); + } + + #[test] + fn test_github_app_token_validate_rejects_non_https_api_url() { + let yaml = r#" +id: copilot +github-app-token: + app-id: 1234567 + owner: octo-org + api-url: http://insecure.example.com/api/v3 +"#; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let gat = EngineConfig::Full(Box::new(opts)) + .github_app_token() + .unwrap() + .clone(); + let err = gat.validate().unwrap_err().to_string(); + assert!(err.contains("api-url"), "unexpected error: {err}"); + } + + #[test] + fn test_engine_github_app_token_absent_by_default() { + let ec = EngineConfig::default(); + assert!(ec.github_app_token().is_none()); + let opts: EngineOptions = serde_yaml::from_str("id: copilot").unwrap(); + assert!(EngineConfig::Full(Box::new(opts)).github_app_token().is_none()); + } + + #[test] + fn test_engine_github_app_token_repositories_optional() { + let yaml = r#" +id: copilot +github-app-token: + app-id: 1234567 + owner: octo-org +"#; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let gat = EngineConfig::Full(Box::new(opts)).github_app_token().unwrap().clone(); + assert!(gat.repositories.is_empty()); + gat.validate().unwrap(); + } + + #[test] + fn test_github_app_token_validate_rejects_bad_app_id() { + let gat = GithubAppTokenConfig { + app_id: "not a valid id".to_string(), + private_key: None, + owner: "octo-org".to_string(), + repositories: vec![], + api_url: None, + skip_token_revocation: false, + }; + let err = gat.validate().unwrap_err().to_string(); + assert!(err.contains("app-id"), "unexpected error: {err}"); + } + + #[test] + fn test_github_app_token_validate_rejects_negative_app_id() { + // A negative unquoted integer (app-id: -7654321) stringifies to + // "-7654321"; the leading '-' must be rejected (it is in the charset + // but is not a valid App ID and would produce a bad JWT `iss`). + let yaml = r#" +id: copilot +github-app-token: + app-id: -7654321 + owner: octo-org +"#; + let opts: EngineOptions = serde_yaml::from_str(yaml).unwrap(); + let gat = EngineConfig::Full(Box::new(opts)) + .github_app_token() + .unwrap() + .clone(); + assert_eq!(gat.app_id, "-7654321"); + let err = gat.validate().unwrap_err().to_string(); + assert!(err.contains("app-id"), "unexpected error: {err}"); + } + + #[test] + fn test_github_app_token_validate_rejects_bad_private_key_override() { + let gat = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: Some("not a var".to_string()), + owner: "octo-org".to_string(), + repositories: vec![], + api_url: None, + skip_token_revocation: false, + }; + let err = gat.validate().unwrap_err().to_string(); + assert!(err.contains("private-key"), "unexpected error: {err}"); + } + + #[test] + fn test_github_app_token_validate_rejects_bad_owner() { + let gat = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: None, + owner: "octo/org".to_string(), + repositories: vec![], + api_url: None, + skip_token_revocation: false, + }; + let err = gat.validate().unwrap_err().to_string(); + assert!(err.contains("owner"), "unexpected error: {err}"); + } + + #[test] + fn test_github_app_token_validate_rejects_bad_repository() { + let gat = GithubAppTokenConfig { + app_id: "1234567".to_string(), + private_key: None, + owner: "octo-org".to_string(), + repositories: vec!["ok-repo".to_string(), "bad;repo".to_string()], + api_url: None, + skip_token_revocation: false, + }; + let err = gat.validate().unwrap_err().to_string(); + assert!(err.contains("repositories"), "unexpected error: {err}"); + } + // ─── PermissionsConfig deserialization ─────────────────────────────── #[test] diff --git a/src/engine.rs b/src/engine.rs index 4715abce..bc8bdd27 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -610,9 +610,75 @@ fn copilot_args( Ok(params.join(" ")) } +/// The masked, same-job pipeline variable the `github-app-token` ado-script +/// bundle sets. When `engine.github-app-token` is configured, the Copilot +/// engine's `GITHUB_TOKEN` is sourced from this variable (set by the mint step +/// earlier in the same job) instead of the operator-provided `GITHUB_TOKEN` +/// pipeline variable. +pub const GITHUB_APP_TOKEN_VAR: &str = "GITHUB_APP_TOKEN"; + +/// Return the ADO pipeline-variable name that `GITHUB_TOKEN` should be sourced +/// from for the Copilot engine, given the engine config. When +/// `engine.github-app-token` is set this is [`GITHUB_APP_TOKEN_VAR`] (minted +/// same-job by the token step); otherwise it is the operator-provided +/// `GITHUB_TOKEN` pipeline variable. +pub fn github_token_source_var(engine_config: &EngineConfig) -> &'static str { + if engine_config.github_app_token().is_some() { + GITHUB_APP_TOKEN_VAR + } else { + "GITHUB_TOKEN" + } +} + +/// Cross-field validation: reject engine-specific config that only the Copilot +/// engine wires up when it is set alongside a different `engine.id`. Without +/// this, `engine.github-app-token` on a non-Copilot engine would silently be a +/// no-op — the mint/revoke steps would run but `copilot_env` (the only place +/// `GITHUB_TOKEN` is sourced from `$(GITHUB_APP_TOKEN)`) is Copilot-only. +/// +/// Modelled on gh-aw's engine-gated config validation (e.g. +/// `validateMaxToolDenialsSupport`, which hard-errors "…supported only with +/// engine 'copilot'…"). ado-aw currently only supports the Copilot engine +/// (`get_engine` rejects other ids), so this is primarily a future-proofing +/// guard + a precise error message; when a second engine is added it becomes +/// the load-bearing check that prevents a silent no-op. +pub fn validate_engine_feature_support(engine_config: &EngineConfig) -> Result<()> { + let id = engine_config.engine_id(); + if engine_config.github_app_token().is_some() && id != "copilot" { + anyhow::bail!( + "engine.github-app-token is only supported for engine.id = copilot (got '{id}'). \ + The minted installation token is wired into GITHUB_TOKEN only on the Copilot \ + engine path; on another engine it would be a no-op. Remove github-app-token or \ + set engine.id to copilot." + ); + } + Ok(()) +} + +/// Non-blocking advisory emitted at compile time when `engine.github-app-token` +/// is configured. The compiler cannot introspect ADO variable metadata, so it +/// cannot verify that the referenced `private-key` variable is actually marked +/// **secret** — it only validates the variable *name*. This advisory reminds +/// the author to store the private key as a secret (via `ado-aw secrets set`), +/// which is the one thing they must get right and the compiler can't enforce. +/// +/// Returns `None` when the feature is not used, so the caller emits nothing for +/// the common case. Naming the specific variable keeps the message actionable. +pub fn github_app_token_secrecy_advisory(engine_config: &EngineConfig) -> Option { + engine_config.github_app_token().map(|cfg| { + format!( + "engine.github-app-token uses pipeline variable '{0}' for the GitHub App \ + private key. Ensure '{0}' is stored as a SECRET (e.g. `ado-aw secrets set {0} \ + \"$(cat key.pem)\"`); the compiler cannot verify a variable is marked secret.", + cfg.private_key_var() + ) + }) +} + fn copilot_env(engine_config: &EngineConfig) -> Result { + let token_var = github_token_source_var(engine_config); let mut lines: Vec = vec![ - "GITHUB_TOKEN: $(GITHUB_TOKEN)".to_string(), + format!("GITHUB_TOKEN: $({token_var})"), "GITHUB_READ_ONLY: 1".to_string(), "COPILOT_OTEL_ENABLED: \"true\"".to_string(), "COPILOT_OTEL_EXPORTER_TYPE: \"file\"".to_string(), @@ -994,8 +1060,9 @@ fn copilot_invocation( #[cfg(test)] mod tests { use super::{ - Engine, copilot_byom_active, copilot_byom_credential_keys, copilot_provider_env, - get_engine, normalize_version_tag, + Engine, GITHUB_APP_TOKEN_VAR, copilot_byom_active, copilot_byom_credential_keys, + copilot_provider_env, get_engine, github_app_token_secrecy_advisory, + github_token_source_var, normalize_version_tag, validate_engine_feature_support, }; use crate::compile::{ extensions::{CompileContext, CompilerExtension, Declarations, collect_extensions}, @@ -1052,6 +1119,75 @@ mod tests { assert!(!env.contains("AZURE_DEVOPS_EXT_PAT")); } + #[test] + fn copilot_engine_env_sources_github_token_from_app_token_var_when_configured() { + let src = "---\nname: test\ndescription: test\nengine:\n id: copilot\n \ + github-app-token:\n app-id: GH_APP_ID\n private-key: GH_APP_KEY\n \ + owner: octo-org\n---\n"; + let (front_matter, _) = parse_markdown(src).unwrap(); + let env = Engine::Copilot.env(&front_matter.engine).unwrap(); + assert!( + env.contains("GITHUB_TOKEN: $(GITHUB_APP_TOKEN)"), + "expected GITHUB_APP_TOKEN source, got:\n{env}" + ); + assert!(!env.contains("GITHUB_TOKEN: $(GITHUB_TOKEN)")); + } + + #[test] + fn github_token_source_var_switches_on_config() { + let (default_fm, _) = + parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + assert_eq!(github_token_source_var(&default_fm.engine), "GITHUB_TOKEN"); + + let src = "---\nname: test\ndescription: test\nengine:\n id: copilot\n \ + github-app-token:\n app-id: GH_APP_ID\n private-key: GH_APP_KEY\n \ + owner: octo-org\n---\n"; + let (app_fm, _) = parse_markdown(src).unwrap(); + assert_eq!(github_token_source_var(&app_fm.engine), GITHUB_APP_TOKEN_VAR); + } + + #[test] + fn github_app_token_secrecy_advisory_fires_only_when_configured() { + let (default_fm, _) = + parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + assert!(github_app_token_secrecy_advisory(&default_fm.engine).is_none()); + + let src = "---\nname: test\ndescription: test\nengine:\n id: copilot\n \ + github-app-token:\n app-id: 1234567\n owner: octo-org\n---\n"; + let (app_fm, _) = parse_markdown(src).unwrap(); + let advisory = github_app_token_secrecy_advisory(&app_fm.engine) + .expect("advisory present when github-app-token configured"); + // Names the effective (default) variable and points at secrecy. + assert!(advisory.contains("GITHUB_APP_PRIVATE_KEY"), "advisory: {advisory}"); + assert!(advisory.contains("SECRET"), "advisory: {advisory}"); + } + + #[test] + fn validate_engine_feature_support_rejects_github_app_token_on_non_copilot() { + // github-app-token on a non-copilot engine is a hard error (gh-aw + // pattern for engine-gated config), preventing a silent no-op. + let src = "---\nname: test\ndescription: test\nengine:\n id: claude\n \ + github-app-token:\n app-id: 1234567\n owner: octo-org\n---\n"; + let (fm, _) = parse_markdown(src).unwrap(); + let err = validate_engine_feature_support(&fm.engine).unwrap_err().to_string(); + assert!(err.contains("github-app-token"), "err: {err}"); + assert!(err.contains("copilot"), "err: {err}"); + } + + #[test] + fn validate_engine_feature_support_allows_copilot_and_absent() { + // Copilot + github-app-token: ok. + let src = "---\nname: test\ndescription: test\nengine:\n id: copilot\n \ + github-app-token:\n app-id: 1234567\n owner: octo-org\n---\n"; + let (fm, _) = parse_markdown(src).unwrap(); + validate_engine_feature_support(&fm.engine).expect("copilot + app-token is valid"); + + // No github-app-token: ok regardless of engine. + let (default_fm, _) = + parse_markdown("---\nname: test\ndescription: test\n---\n").unwrap(); + validate_engine_feature_support(&default_fm.engine).expect("absent config is valid"); + } + #[test] fn get_engine_resolves_copilot() { let engine = get_engine("copilot").unwrap(); diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 4ec247e3..03c1ddfb 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -7288,3 +7288,280 @@ description: "no fetch tuning" ); } +// ─── GitHub App-backed Copilot engine auth (issue #1316) ───────────────────── + +/// Compile inline agent `content` in an isolated temp dir and return the +/// compiled YAML. Panics on compile failure. +fn compile_inline_agent(tag: &str, content: &str) -> String { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-{tag}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&temp_dir); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + let input = temp_dir.join(format!("{tag}-agent.md")); + fs::write(&input, content).expect("Failed to write test input"); + let output_path = temp_dir.join(format!("{tag}-agent.yml")); + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args([ + "compile", + input.to_str().unwrap(), + "-o", + output_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run compiler"); + assert!( + output.status.success(), + "compile should succeed for {tag}.\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let compiled = fs::read_to_string(&output_path).expect("Compiled YAML should exist"); + let _ = fs::remove_dir_all(&temp_dir); + compiled +} + +const GITHUB_APP_TOKEN_FM: &str = r#" +engine: + id: copilot + github-app-token: + app-id: 1234567 + owner: octo-org + repositories: [octo-repo] +"#; + +/// Assert the GitHub App token wiring is present in exactly the Agent and +/// Detection jobs (two mint steps, two `GITHUB_APP_TOKEN`-sourced tokens) and +/// nowhere else, for a given compiled pipeline. +fn assert_github_app_token_wiring(compiled: &str) { + // Total bundle invocations = mint (Agent + Detection) + revoke + // (Agent + Detection) = 4. Revoke invocations carry the ` revoke` arg. + let total_bundle = compiled + .matches("node '/tmp/ado-aw-scripts/ado-script/github-app-token.js'") + .count(); + let revoke_hits = compiled + .matches("node '/tmp/ado-aw-scripts/ado-script/github-app-token.js' revoke") + .count(); + let mint_hits = total_bundle - revoke_hits; + assert_eq!( + mint_hits, 2, + "expected the mint step in exactly Agent + Detection, found {mint_hits}:\n{compiled}" + ); + let mint_display = compiled + .matches("Mint GitHub App token (Copilot engine auth)") + .count(); + assert_eq!(mint_display, 2, "expected two mint-step display names"); + + // GITHUB_TOKEN is sourced from the minted masked variable in both Copilot + // envs (agent + detection), never from the operator's $(GITHUB_TOKEN). + let app_token_src = compiled.matches("GITHUB_TOKEN: $(GITHUB_APP_TOKEN)").count(); + assert_eq!( + app_token_src, 2, + "expected GITHUB_TOKEN sourced from $(GITHUB_APP_TOKEN) in agent + detection:\n{compiled}" + ); + assert!( + !compiled.contains("GITHUB_TOKEN: $(GITHUB_TOKEN)"), + "no Copilot env should use the operator $(GITHUB_TOKEN) when App auth is configured:\n{compiled}" + ); + + // Non-secret inputs are single-quoted argv flags (shadow-proof); the app-id + // is a single-quoted literal and the private key (default variable name, + // since the fixture omits `private-key`) is the only GH_APP_* env var. + assert!( + compiled.contains("--app-id '1234567'"), + "app-id must be a single-quoted literal argv flag:\n{compiled}" + ); + assert!(compiled.contains("GH_APP_PRIVATE_KEY: $(GITHUB_APP_PRIVATE_KEY)")); + // The private key is the ONLY GH_APP_* env var; every other input is argv. + for key in [ + "GH_APP_ID:", + "GH_APP_OWNER:", + "GH_APP_REPOSITORIES:", + "GH_APP_OUTPUT_VAR:", + "GH_APP_API_URL:", + ] { + assert!( + !compiled.contains(key), + "{key} must not be an env var (non-secret inputs are argv):\n{compiled}" + ); + } + + // The output variable name is pinned as an argv flag in both mint steps, so + // no pipeline variable named GH_APP_OUTPUT_VAR can redirect the minted + // token (argv comes only from the compiler-authored script). + let output_var_pins = compiled + .matches("--output-var 'GITHUB_APP_TOKEN'") + .count(); + assert_eq!( + output_var_pins, 2, + "expected --output-var pinned to GITHUB_APP_TOKEN in both mint steps:\n{compiled}" + ); + + // By default the token is revoked after the Copilot run in both jobs + // (revoke_hits computed above). + assert_eq!( + revoke_hits, 2, + "expected a revoke step in Agent + Detection by default, found {revoke_hits}:\n{compiled}" + ); + assert!( + compiled.contains("GH_APP_TOKEN: $(GITHUB_APP_TOKEN)"), + "revoke step reads the minted token from $(GITHUB_APP_TOKEN):\n{compiled}" + ); +} + +#[test] +fn test_github_app_token_wiring_standalone() { + let content = format!( + "---\nname: \"GH App Standalone\"\ndescription: \"gh app token\"{GITHUB_APP_TOKEN_FM}---\n\n## Agent\n\nDo work.\n" + ); + let compiled = compile_inline_agent("ghapp-standalone", &content); + assert_github_app_token_wiring(&compiled); +} + +#[test] +fn test_github_app_token_wiring_all_targets() { + for target in ["1es", "job", "stage"] { + let content = format!( + "---\nname: \"GH App {target}\"\ndescription: \"gh app token\"\ntarget: {target}{GITHUB_APP_TOKEN_FM}---\n\n## Agent\n\nDo work.\n" + ); + let compiled = compile_inline_agent(&format!("ghapp-{target}"), &content); + assert_github_app_token_wiring(&compiled); + } +} + +#[test] +fn test_no_github_app_token_by_default() { + let content = "---\nname: \"No GH App\"\ndescription: \"default token\"\n---\n\n## Agent\n\nDo work.\n"; + let compiled = compile_inline_agent("ghapp-absent", content); + assert!( + !compiled.contains("github-app-token.js"), + "default agent must not emit the mint step:\n{compiled}" + ); + assert!( + !compiled.contains("GITHUB_APP_TOKEN"), + "default agent must not reference GITHUB_APP_TOKEN:\n{compiled}" + ); + assert!( + compiled.contains("GITHUB_TOKEN: $(GITHUB_TOKEN)"), + "default agent sources GITHUB_TOKEN from the operator variable:\n{compiled}" + ); +} + +#[test] +fn test_github_app_token_skip_revocation() { + let content = "---\nname: \"GH App No Revoke\"\ndescription: \"gh app token, no revoke\"\nengine:\n id: copilot\n github-app-token:\n app-id: 1234567\n owner: octo-org\n skip-token-revocation: true\n---\n\n## Agent\n\nDo work.\n"; + let compiled = compile_inline_agent("ghapp-norevoke", content); + // Mint step still present in Agent + Detection. + assert_eq!( + compiled + .matches("node '/tmp/ado-aw-scripts/ado-script/github-app-token.js'") + .count() + - compiled + .matches("node '/tmp/ado-aw-scripts/ado-script/github-app-token.js' revoke") + .count(), + 2, + "mint step must still be present:\n{compiled}" + ); + // ...but no revoke step. + assert!( + !compiled.contains("github-app-token.js' revoke"), + "skip-token-revocation must suppress the revoke step:\n{compiled}" + ); +} + +#[test] +fn test_github_app_token_rejected_on_non_copilot_engine() { + // github-app-token on a non-copilot engine must be a hard compile error + // (not a silent no-op): the minted token is only wired into GITHUB_TOKEN on + // the Copilot path. + let source = "---\nname: \"GH App Wrong Engine\"\ndescription: \"non-copilot + app token\"\nengine:\n id: claude\n github-app-token:\n app-id: 1234567\n owner: octo-org\n---\n\n## Agent\n\nDo work.\n"; + let (ok, _compiled, stderr) = compile_inline_source("ghapp-wrong-engine", source); + assert!( + !ok, + "compile must fail for github-app-token on a non-copilot engine.\nstderr: {stderr}" + ); + assert!( + stderr.contains("github-app-token") && stderr.contains("copilot"), + "error must explain github-app-token requires the copilot engine.\nstderr: {stderr}" + ); +} + +#[test] +fn test_github_app_token_literal_app_id_and_api_url() { + // This fixture also exercises the private-key OVERRIDE (explicit GH_APP_KEY). + let content = "---\nname: \"GH App Literal\"\ndescription: \"literal app id + ghes\"\nengine:\n id: copilot\n github-app-token:\n app-id: 1234567\n private-key: GH_APP_KEY\n owner: octo-org\n api-url: https://ghe.example.com/api/v3\n---\n\n## Agent\n\nDo work.\n"; + let compiled = compile_inline_agent("ghapp-literal", content); + // Numeric app-id is emitted verbatim as a single-quoted argv flag, not a macro. + assert!( + compiled.contains("--app-id '1234567'"), + "literal numeric app-id must be a single-quoted verbatim argv flag:\n{compiled}" + ); + assert!( + !compiled.contains("--app-id '$("), + "literal app-id must not be treated as a variable macro:\n{compiled}" + ); + // The private-key override names GH_APP_KEY as the masked secret env var. + assert!( + compiled.contains("GH_APP_PRIVATE_KEY: $(GH_APP_KEY)"), + "private-key override must source the secret from $(GH_APP_KEY):\n{compiled}" + ); + assert!( + !compiled.contains("$(GITHUB_APP_PRIVATE_KEY)"), + "override must replace the default private-key variable:\n{compiled}" + ); + // GHES api-url flows into both mint and revoke steps as an argv flag. + // Mint: `... --api-url '...'`; revoke: `revoke --api-url '...'`. + let api_url_args = compiled + .matches("--api-url 'https://ghe.example.com/api/v3'") + .count(); + assert_eq!( + api_url_args, 4, + "api-url must appear as an argv flag in both mint and both revoke steps (2 jobs x 2):\n{compiled}" + ); + assert!( + !compiled.contains("GH_APP_API_URL:"), + "api-url must be argv, never an env var:\n{compiled}" + ); +} + +/// When another ado-script bundle feature is active in the Agent job (here a +/// safe-output activates the approval-summary bundle download), the mint step +/// must NOT trigger a second bundle download in that job — it reuses the +/// already-staged bundle. Proven by a delta: adding `github-app-token` to an +/// otherwise-identical workflow adds exactly ONE bundle download (the +/// Detection job, which has no extension-prepare phase), never two. +#[test] +fn test_github_app_token_reuses_staged_bundle_in_agent() { + fn count_downloads(compiled: &str) -> usize { + compiled.matches("Download ado-aw scripts").count() + + compiled.matches("Stage ado-aw scripts").count() + } + let safe_output = "safe-outputs:\n create-work-item:\n work-item-type: Task\n"; + + let without = compile_inline_agent( + "ghapp-dedupe-without", + &format!( + "---\nname: \"No GH App SO\"\ndescription: \"safe output only\"\n{safe_output}---\n\n## Agent\n\nDo work.\n" + ), + ); + let with = compile_inline_agent( + "ghapp-dedupe-with", + &format!( + "---\nname: \"GH App SO\"\ndescription: \"gh app token with safe output\"{GITHUB_APP_TOKEN_FM}{safe_output}---\n\n## Agent\n\nDo work.\n" + ), + ); + assert_github_app_token_wiring(&with); + + // Adding github-app-token stages the bundle only in Detection (Agent + // reuses its already-staged copy), so the download count grows by exactly 1. + assert_eq!( + count_downloads(&with), + count_downloads(&without) + 1, + "github-app-token must add exactly one bundle download (Detection), \ + proving the Agent job reuses its staged bundle rather than \ + double-downloading. without={}, with={}", + count_downloads(&without), + count_downloads(&with), + ); +}