diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3fdeffe..02841dbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -468,14 +468,16 @@ jobs: # - `full`: adds the full keyed suites (protocol-routing, opencode-live, # dispatch-levels). Nightly and on dispatch. # - # The keys are passed only when the tier is not `none`, and the tier is - # always set explicitly: unset means `full` (so a local run with keys arms - # everything), and a dropped env line would arm the full suites on every - # pull request that arms the probes. Secrets are not exposed to fork or Dependabot PRs, and every - # credentialed case skips when its key is absent, so this stays green there - # while still checking that the endpoints answer. Add OPENCODE_API_KEY (Zen) - # and OPENCODE_GO_API_KEY (Go plan) under Settings → Secrets → Actions to arm - # the keyed cases. + # The keys are armed only when the tier is not `none`, by the sign-in step: + # GitHub mints a short-lived OIDC token for the Console's audience, the + # Console exchanges it for an access token, and only that token reaches the + # suite's environment. Nothing long-lived is stored in this repository or in + # Actions secrets. A fork or Dependabot pull request is issued neither an + # OIDC endpoint nor secrets, so the step exits with a notice and the keyed + # cases skip exactly as they do when the tier is `none`, while the keyless + # cases still check that the endpoints answer. The Console CI token's client + # id is public by design: the exchange binds it to the audience and the + # GitHub OIDC subject, so the id alone grants nothing. # # Both probe suites write a dated table of what each URL shape answered # (docs/redirect-planes.md §13); the job appends them to the run summary and @@ -484,6 +486,9 @@ jobs: name: e2e · live gateway runs-on: ubuntu-latest timeout-minutes: 30 + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -534,13 +539,37 @@ jobs: node-version: 26 cache: pnpm - run: pnpm install --frozen-lockfile + - name: Sign in to OpenCode Console + if: steps.tier.outputs.keyed != 'none' + env: + AUDIENCE: https://opencode.ai/console + CLIENT_ID: sk_baf57eb6-1b63-4eb7-a538-737dc3aea54d + run: | + # A fork or Dependabot pull request is issued no OIDC endpoint; leave + # the suite keyless, as its keyed cases already expect. + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::notice::no OIDC identity for this run; the keyed cases will skip" + exit 0 + fi + oidc=$(curl -sSf --oauth2-bearer "$ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r .value) + token=$(curl -sSf https://opencode.ai/console/auth/oauth/token \ + -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ + -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \ + -d client_id="$CLIENT_ID" \ + -d subject_token="$oidc" | jq -r .access_token) + echo "::add-mask::$token" + # One workspace credential serves both planes; the suite reads the two + # names it always has, and only this step arms them. + { + echo "OPENCODE_API_KEY=$token" + echo "OPENCODE_GO_API_KEY=$token" + } >> "$GITHUB_ENV" - run: pnpm run test:e2e env: OPENCODE_E2E: "1" OPENCODE_E2E_KEYED: ${{ steps.tier.outputs.keyed }} OPENCODE_E2E_RECORD_DIR: ${{ runner.temp }}/endpoint-families - OPENCODE_API_KEY: ${{ steps.tier.outputs.keyed != 'none' && secrets.OPENCODE_API_KEY || '' }} - OPENCODE_GO_API_KEY: ${{ steps.tier.outputs.keyed != 'none' && secrets.OPENCODE_GO_API_KEY || '' }} - name: publish the endpoint-family record to the run summary if: always() env: diff --git a/test/keyed-probes.test.ts b/test/keyed-probes.test.ts index b2fc1d3f..1f08cab6 100644 --- a/test/keyed-probes.test.ts +++ b/test/keyed-probes.test.ts @@ -324,12 +324,13 @@ describe("keyed probes · the cells", () => { }); it("every live suite reads a key only through E2E.keyFor at its own tier: the keyed probes at `probes`, the full keyed suites at `full`, the keyless matrix never", () => { - // The live files are not loaded offline, and ci.yml passes both secrets - // on a `probes` pull request, so a full suite that read a key straight - // from the environment would spend it on every such pull request, and - // keyed probes armed at `full` would never probe one: both stay green - // everywhere else. `E2E.keyFor` (test/support/e2e-env.ts) is the one - // reader, and it arms a key through `armedKey` at the tier it is asked for. + // The live files are not loaded offline, and ci.yml arms both keys on any + // tier but `none` (the sign-in step mints them), so a full suite that read + // a key straight from the environment would spend it on every such pull + // request, and keyed probes armed at `full` would never probe one: both + // stay green everywhere else. `E2E.keyFor` (test/support/e2e-env.ts) is + // the one reader, and it arms a key through `armedKey` at the tier it is + // asked for. const NEED: Readonly> = { "dispatch-levels.e2e.ts": "full", "keyed-probes.e2e.ts": "probes", @@ -1188,6 +1189,13 @@ describe("keyed probes · what a plane decides", () => { const expression = (inner: string): string => ["$", "{{ ", inner, " }}"].join(""); +/** + * A shell `${inner}` expansion, as a workflow spells it, without a literal + * `${` in a regular string (`no-template-curly-in-string` reads that one as a + * mistyped template literal). + */ +const expansion = (inner: string): string => ["$", "{", inner, "}"].join(""); + describe("keyed probes · the CI wiring", () => { const ci = readFileSync( new URL("../.github/workflows/ci.yml", import.meta.url), @@ -1328,23 +1336,56 @@ describe("keyed probes · the CI wiring", () => { ); }); - it("the test step names the tier and passes the keys only when it is not `none`", () => { + it("the sign-in step mints the key from the runner's OIDC identity, and only when a tier spends one", () => { + // The job must be allowed to ask for an OIDC token; without `id-token` + // the runner sets no request URL and the exchange can never run. + expect(job).toMatch( + /\n {4}permissions:\n {6}contents: read\n {6}id-token: write\n {4}steps:/u + ); + const signIn = + steps.find((step) => + step.includes("name: Sign in to OpenCode Console") + ) ?? ""; + expect(signIn).not.toBe(""); + // One sign-in, at the tiers that spend a key, and not at `none`. + expect(signIn).toMatch(/^\s*if: steps\.tier\.outputs\.keyed != 'none'$/mu); + for (const line of [ + "https://opencode.ai/console/auth/oauth/token", + "grant_type=urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type=urn:ietf:params:oauth:token-type:jwt", + "client_id=", + "subject_token=", + "&audience=$AUDIENCE", + "::add-mask::", + "OPENCODE_API_KEY=$token", + "OPENCODE_GO_API_KEY=$token", + "$GITHUB_ENV", + // A fork is issued no OIDC request URL: the step must leave the suite + // keyless rather than fail on a request it cannot make. + expansion("ACTIONS_ID_TOKEN_REQUEST_URL:-"), + ]) { + expect(signIn, line).toContain(line); + } + // No stored key is referenced anywhere in the job: the token is minted per + // run, so nothing long-lived sits in this public repository. + for (const step of steps) { + expect(step).not.toContain("secrets.OPENCODE"); + } + }); + + it("the test step names the tier and the record directory, and no key: the sign-in step owns them", () => { // Unset means `full`, so a step that dropped the tier line would arm the // full keyed suites on every pull request that arms the probes. for (const line of [ `OPENCODE_E2E_KEYED: ${expression("steps.tier.outputs.keyed")}`, - `OPENCODE_API_KEY: ${expression("steps.tier.outputs.keyed != 'none' && secrets.OPENCODE_API_KEY || ''")}`, - `OPENCODE_GO_API_KEY: ${expression("steps.tier.outputs.keyed != 'none' && secrets.OPENCODE_GO_API_KEY || ''")}`, "OPENCODE_E2E_RECORD_DIR", ]) { expect(testStep, line).toContain(line); } - // And no other step of the job sees a key. - for (const step of steps) { - if (step !== testStep) { - expect(step).not.toContain("secrets.OPENCODE"); - } - } + // The keys arrive through GITHUB_ENV from the sign-in step, not here: a + // step-level env entry would shadow the minted token with a stale one. + expect(testStep).not.toContain("OPENCODE_API_KEY"); + expect(testStep).not.toContain("OPENCODE_GO_API_KEY"); }); });