From b19ec31fa4824eaa7a78c486efa7db1d073fede7 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Wed, 9 Sep 2026 23:51:29 -0400 Subject: [PATCH 1/6] feat(ci): mint the gateway token from a GitHub identity token The smoke test authenticates the mint with a personal API key, which the mint refuses; the run only passes because it falls back to the legacy gateway, and that fallback is going away. The workflow now asks GitHub for an identity token and passes it as POSTHOG_WIZARD_GATEWAY_TOKEN. The mint reads that bearer when it is set, so CI receives the same capped, program-pinned token a user's run gets. The personal key stays for API calls and for the legacy fallback, which must never see the identity token. The smoke-test step blanks the identity-token request variables, so the model-written code it runs in the sandbox cannot ask for tokens of its own. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/smoke-test.yml | 22 +++++++ src/lib/__tests__/gateway-session.test.ts | 78 +++++++++++++++++++++++ src/lib/gateway-session.ts | 21 ++++-- 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 4c4ec297..629c7b12 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -19,6 +19,10 @@ permissions: jobs: smoke-test: + permissions: + contents: read + # Lets this job ask GitHub for the identity token the wizard mint verifies. + id-token: write name: Smoke Test (${{ inputs.app || 'basic-integration/next-js/15-app-router-todo' }}) runs-on: ubuntu-latest timeout-minutes: 15 @@ -60,9 +64,27 @@ jobs: [ -n "$API_KEY" ] || { echo "::error::GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_PERSONAL_KEY is not set"; exit 1; } [ -n "$PROJECT_ID" ] || { echo "::error::GH_APP_POSTHOG_WIZARD_CI_BOT_TARGET_PROJECT_ID is not set"; exit 1; } + # Proves this workflow's identity to the mint, which pins the repository, + # the owner id, this file's path and the ref before it issues a token. + - name: Request a gateway identity token + env: + AUDIENCE: posthog-wizard-ci + run: | + token="$(curl -sS --fail-with-body \ + -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r '.value')" + [ -n "$token" ] && [ "$token" != "null" ] || { + echo "::error::could not obtain a GitHub identity token"; exit 1; } + echo "::add-mask::$token" + echo "POSTHOG_WIZARD_GATEWAY_TOKEN=$token" >> "$GITHUB_ENV" + - name: Run smoke test env: POSTHOG_PERSONAL_API_KEY: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_PERSONAL_KEY }} + # This step runs model-written code in a sandbox. Blanked so that code + # cannot mint further identity tokens for audiences of its choosing. + ACTIONS_ID_TOKEN_REQUEST_URL: '' + ACTIONS_ID_TOKEN_REQUEST_TOKEN: '' # Required — without it the wizard falls back to the key's default # team and 403s. Same secret name wizard-workbench's wizard-ci.yml uses. POSTHOG_WIZARD_PROJECT_ID: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_TARGET_PROJECT_ID }} diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 1a7a6c3b..32ccb73b 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -821,3 +821,81 @@ describe('isTrustedGatewayUrl', () => { ).toBe(true); }); }); + +describe('gatewayAuth with a CI identity token', () => { + const fetchMock = vi.fn(); + const minted = { + ok: true, + json: () => + Promise.resolve({ + token: 'phe_ci', + expires_at: new Date(Date.now() + 3600_000).toISOString(), + gateway_url: 'https://ai-gateway.us.posthog.com', + }), + }; + + beforeEach(() => { + resetGatewaySession(); + fetchMock.mockReset(); + vi.mocked(logToFile).mockClear(); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', 'header.payload.signature'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('mints with the identity token rather than the personal key', async () => { + fetchMock.mockResolvedValue(minted); + + const auth = await gatewayAuth(host, 'phx_personal', 'integration'); + expect(auth.token).toBe('phe_ci'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://us.posthog.com/api/wizard/gateway_token/', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer header.payload.signature', + }), + }), + ); + }); + + it('keeps the identity token out of the legacy fallback', async () => { + // Only the mint can verify it. The legacy gateway would read it as a + // credential, and it is not one. + setLegacyGatewayFallback(true); + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); + + const auth = await gatewayAuth(host, 'phx_personal', 'integration'); + expect(auth).toMatchObject({ token: 'phx_personal', legacy: true }); + setLegacyGatewayFallback(false); + }); + + it('never writes the identity token to the log', async () => { + fetchMock.mockResolvedValue(minted); + + await gatewayAuth(host, 'phx_personal', 'integration'); + expect(loggedLines().join('\n')).not.toContain('header.payload.signature'); + }); + + it('falls back to the personal key when the variable is blank', async () => { + vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', ' '); + fetchMock.mockResolvedValue(minted); + + await gatewayAuth(host, 'phx_personal', 'integration'); + expect(fetchMock).toHaveBeenCalledWith( + 'https://us.posthog.com/api/wizard/gateway_token/', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer phx_personal', + }), + }), + ); + }); +}); diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 04a707b2..beab7846 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -60,20 +60,30 @@ const MAX_REFUSAL_DETAIL_LENGTH = 500; /** Outcomes are short snake_case labels; anything longer is not one. */ const MAX_REFUSAL_OUTCOME_LENGTH = 64; +/** + * The bearer the mint reads. CI presents a GitHub OIDC token, which only the + * mint accepts, so it never becomes the run's gateway credential and the legacy + * fallback keeps using the user's own token. + */ +function mintBearer(accessToken: string): string { + return process.env.POSTHOG_WIZARD_GATEWAY_TOKEN?.trim() || accessToken; +} + /** Resolve this run's gateway auth, minting and re-minting near expiry. */ export async function gatewayAuth( host: HostResolution, accessToken: string, program: string | undefined, ): Promise { + const bearer = mintBearer(accessToken); // Keyed by program: a token pins `wizard:`, so reusing one across // programs bills the wrong budget. - const key = `${host.apiHost}\n${accessToken}\n${program ?? ''}`; + const key = `${host.apiHost}\n${bearer}\n${program ?? ''}`; if (cached && cached.key === key && Date.now() < cached.staleAtMs) { return cached.auth; } if (inFlight && inFlight.key === key) return inFlight.promise; - const promise = resolveGatewayAuth(host, accessToken, key, program); + const promise = resolveGatewayAuth(host, accessToken, bearer, key, program); inFlight = { key, promise }; try { return await promise; @@ -85,6 +95,7 @@ export async function gatewayAuth( async function resolveGatewayAuth( host: HostResolution, accessToken: string, + bearer: string, key: string, program: string | undefined, ): Promise { @@ -98,7 +109,7 @@ async function resolveGatewayAuth( } let minted: MintedToken; try { - minted = await mintGatewayToken(host, accessToken, program); + minted = await mintGatewayToken(host, bearer, program); } catch (e) { if (!(e instanceof GatewayMintRefused)) throw e; const legacy = legacyGatewayAuth(host, accessToken, e.status); @@ -310,14 +321,14 @@ function mintRefusalMessage(status: number, detail?: string): string { async function mintGatewayToken( host: HostResolution, - accessToken: string, + bearer: string, program: string, ): Promise { try { const resp = await fetch(`${host.apiHost}/api/wizard/gateway_token/`, { method: 'POST', headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json', }, // The flag tells the server this build reads a refusal, so it may answer From 031442a4b838d78679d95a546a4ba8b2a5ca0c80 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Thu, 10 Sep 2026 00:23:26 -0400 Subject: [PATCH 2/6] fix(ci): actually keep the identity-token request out of the sandbox A step-level env: block does not remove ACTIONS_ID_TOKEN_REQUEST_URL or ACTIONS_ID_TOKEN_REQUEST_TOKEN; the runner sets them back afterwards. The smoke test runs model-written code, and those two are permission to ask GitHub for a token naming any audience, so the shell drops them itself instead. The agent subprocess never needed them either, nor the minted identity token: all three join the host-only denylist that already strips the orchestration values. The minted log line now names which identity minted, so a CI run is separable from a user run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/smoke-test.yml | 11 +++--- src/lib/__tests__/gateway-session.test.ts | 37 +++++++++++++++---- .../__tests__/agent-env-isolation.test.ts | 11 ++++++ src/lib/agent/agent-env-isolation.ts | 8 ++++ src/lib/gateway-session.ts | 4 +- 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 629c7b12..0aee392b 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -81,17 +81,18 @@ jobs: - name: Run smoke test env: POSTHOG_PERSONAL_API_KEY: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_PERSONAL_KEY }} - # This step runs model-written code in a sandbox. Blanked so that code - # cannot mint further identity tokens for audiences of its choosing. - ACTIONS_ID_TOKEN_REQUEST_URL: '' - ACTIONS_ID_TOKEN_REQUEST_TOKEN: '' # Required — without it the wizard falls back to the key's default # team and 403s. Same secret name wizard-workbench's wizard-ci.yml uses. POSTHOG_WIZARD_PROJECT_ID: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_TARGET_PROJECT_ID }} POSTHOG_WIZARD_REGION: us WIZARD_WORKBENCH_ROOT: ${{ github.workspace }}/wizard-workbench SMOKE_TEST_APP: ${{ inputs.app || 'basic-integration/next-js/15-app-router-todo' }} - run: ./scripts/smoke-test-ci.sh "$SMOKE_TEST_APP" + run: | + # This step runs model-written code. The runner puts these two back + # after any step env: block, and holding them is permission to ask + # GitHub for tokens naming any audience, so drop them from the shell. + unset ACTIONS_ID_TOKEN_REQUEST_URL ACTIONS_ID_TOKEN_REQUEST_TOKEN + ./scripts/smoke-test-ci.sh "$SMOKE_TEST_APP" - name: Upload artifacts if: always() diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 32ccb73b..0fff9a65 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -866,15 +866,36 @@ describe('gatewayAuth with a CI identity token', () => { // Only the mint can verify it. The legacy gateway would read it as a // credential, and it is not one. setLegacyGatewayFallback(true); - fetchMock.mockResolvedValue({ - ok: false, - status: 401, - json: () => Promise.resolve({}), - }); + try { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); - const auth = await gatewayAuth(host, 'phx_personal', 'integration'); - expect(auth).toMatchObject({ token: 'phx_personal', legacy: true }); - setLegacyGatewayFallback(false); + const auth = await gatewayAuth(host, 'phx_personal', 'integration'); + expect(auth).toMatchObject({ token: 'phx_personal', legacy: true }); + } finally { + setLegacyGatewayFallback(false); + } + }); + + it('mints again when the identity token changes', async () => { + // The session cache keys on the bearer actually sent. Keyed on the personal + // key instead, a second run would serve the first run's token. + fetchMock.mockResolvedValue(minted); + + await gatewayAuth(host, 'phx_personal', 'integration'); + vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', 'second.identity.token'); + await gatewayAuth(host, 'phx_personal', 'integration'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('names the identity that minted, so a CI run is separable in the log', async () => { + fetchMock.mockResolvedValue(minted); + + await gatewayAuth(host, 'phx_personal', 'integration'); + expect(loggedLines().join('\n')).toContain('identity=ci'); }); it('never writes the identity token to the log', async () => { diff --git a/src/lib/agent/__tests__/agent-env-isolation.test.ts b/src/lib/agent/__tests__/agent-env-isolation.test.ts index 29a1feaf..8f882fac 100644 --- a/src/lib/agent/__tests__/agent-env-isolation.test.ts +++ b/src/lib/agent/__tests__/agent-env-isolation.test.ts @@ -36,6 +36,14 @@ describe('isBlockedAgentEnvKey', () => { expect(isBlockedAgentEnvKey('POSTHOG_TASK_ID')).toBe(true); }); + it('blocks the CI identity token and the means to ask for another', () => { + // The pair below is permission to request tokens for any audience the + // holder names, which is the whole of what CI proves to the mint. + expect(isBlockedAgentEnvKey('POSTHOG_WIZARD_GATEWAY_TOKEN')).toBe(true); + expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_URL')).toBe(true); + expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_TOKEN')).toBe(true); + }); + it('still passes through POSTHOG_API_KEY (deliberate, pre-existing disposition)', () => { // The agent may rely on it when writing the user's project key into the // project's own .env; changing that is a separate decision. @@ -135,6 +143,9 @@ describe('sanitizeAgentSubprocessEnv', () => { POSTHOG_HANDOFF_OUTPUT_PATH: '/run/task-42/handoff.md', POSTHOG_TASK_RUN_ID: 'task-42', POSTHOG_TASK_ID: '019abc', + POSTHOG_WIZARD_GATEWAY_TOKEN: 'header.payload.signature', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://pipelines.example/token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'runner-request-token', // — user-facing PostHog config the agent may need for the project .env // (deliberately PRESERVED, pre-existing disposition) — POSTHOG_API_KEY: 'phc_project_key', diff --git a/src/lib/agent/agent-env-isolation.ts b/src/lib/agent/agent-env-isolation.ts index e790fc2f..7cd9c472 100644 --- a/src/lib/agent/agent-env-isolation.ts +++ b/src/lib/agent/agent-env-isolation.ts @@ -56,6 +56,11 @@ const BLOCKED_OFF_NAMESPACE_KEYS = new Set(['AWS_BEARER_TOKEN_BEDROCK']); * read by the wizard's analytics only; they let the agent fingerprint the * run directory where the handoff path typically sits. * + * - `POSTHOG_WIZARD_GATEWAY_TOKEN` and the two `ACTIONS_ID_TOKEN_REQUEST_*` + * values are CI identity: the first is the bearer the mint verifies, the pair + * lets a holder ask GitHub for more of them, for any audience it names. Only + * the wizard process itself mints, so the agent never needs either. + * * Deliberately NOT stripped: `POSTHOG_API_KEY` / `POSTHOG_HOST` — pre-existing * passthrough that the agent may rely on when writing the user's project key * into the project's own .env. Changing that disposition is a separate, @@ -65,6 +70,9 @@ const HOST_ONLY_ENV_KEYS = new Set([ 'POSTHOG_HANDOFF_OUTPUT_PATH', 'POSTHOG_TASK_RUN_ID', 'POSTHOG_TASK_ID', + 'POSTHOG_WIZARD_GATEWAY_TOKEN', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', ]); /** diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index beab7846..dadb5967 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -138,7 +138,9 @@ async function resolveGatewayAuth( logToFile( `[gateway] minted a scoped token: program=${program} team=${ minted.teamId ?? 'unknown' - } ttl=${Math.round(ttlMs / 1000)}s url=${minted.gatewayUrl}`, + } ttl=${Math.round(ttlMs / 1000)}s url=${minted.gatewayUrl} identity=${ + bearer === accessToken ? 'user' : 'ci' + }`, ); const auth: GatewayAuth = { gatewayUrl: minted.gatewayUrl, From c2b35d6132cd03f4f2569feffc2d4940f33f0256 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Thu, 10 Sep 2026 08:14:36 -0400 Subject: [PATCH 3/6] fix(ci): request the identity token where it is spent, and fail without it The token was requested in its own workflow step, then aged through the build, pack and install before the mint read it. GitHub does not document how long one lives. The script now asks for it immediately before the wizard runs and drops the request variables before starting it, so the workflow step no longer needs to blank anything. A CI run whose mint refuses no longer falls back to the legacy gateway. The refusal a broken identity path produces is a 401, which the fallback admits, so a green smoke test could have hidden it while spending on the gateway this work exists to stop using. A run without id-token: write fails with that reason for the same purpose. The subprocess denylist takes the whole ACTIONS_ID_TOKEN_REQUEST namespace rather than the two names that exist today, and the minted log line names which identity minted rather than re-deriving it by comparing two secrets. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/smoke-test.yml | 24 ++------- scripts/smoke-test-ci.sh | 28 ++++++++++ src/lib/__tests__/gateway-session.test.ts | 54 +++++++++++++++++-- .../__tests__/agent-env-isolation.test.ts | 10 +++- src/lib/agent/agent-env-isolation.ts | 17 +++--- src/lib/gateway-session.ts | 26 ++++++--- 6 files changed, 122 insertions(+), 37 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 0aee392b..9c261751 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -64,20 +64,6 @@ jobs: [ -n "$API_KEY" ] || { echo "::error::GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_PERSONAL_KEY is not set"; exit 1; } [ -n "$PROJECT_ID" ] || { echo "::error::GH_APP_POSTHOG_WIZARD_CI_BOT_TARGET_PROJECT_ID is not set"; exit 1; } - # Proves this workflow's identity to the mint, which pins the repository, - # the owner id, this file's path and the ref before it issues a token. - - name: Request a gateway identity token - env: - AUDIENCE: posthog-wizard-ci - run: | - token="$(curl -sS --fail-with-body \ - -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r '.value')" - [ -n "$token" ] && [ "$token" != "null" ] || { - echo "::error::could not obtain a GitHub identity token"; exit 1; } - echo "::add-mask::$token" - echo "POSTHOG_WIZARD_GATEWAY_TOKEN=$token" >> "$GITHUB_ENV" - - name: Run smoke test env: POSTHOG_PERSONAL_API_KEY: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_PERSONAL_KEY }} @@ -87,12 +73,10 @@ jobs: POSTHOG_WIZARD_REGION: us WIZARD_WORKBENCH_ROOT: ${{ github.workspace }}/wizard-workbench SMOKE_TEST_APP: ${{ inputs.app || 'basic-integration/next-js/15-app-router-todo' }} - run: | - # This step runs model-written code. The runner puts these two back - # after any step env: block, and holding them is permission to ask - # GitHub for tokens naming any audience, so drop them from the shell. - unset ACTIONS_ID_TOKEN_REQUEST_URL ACTIONS_ID_TOKEN_REQUEST_TOKEN - ./scripts/smoke-test-ci.sh "$SMOKE_TEST_APP" + # The script asks GitHub for the identity token itself, immediately before + # the run that spends it, and drops the request variables before the + # wizard starts. Doing it here would age the token through the build. + run: ./scripts/smoke-test-ci.sh "$SMOKE_TEST_APP" - name: Upload artifacts if: always() diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh index 80131234..5adee9eb 100755 --- a/scripts/smoke-test-ci.sh +++ b/scripts/smoke-test-ci.sh @@ -131,6 +131,34 @@ if [ ! -f "$WIZARD_BIN" ]; then exit 1 fi +# ── Gateway identity, for CI only ─────────────────────────────────────────── +# Requested here rather than in the workflow because the build and installs +# above take minutes and the token is short-lived. Seconds old at the mint. +if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] && [ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "==> Requesting a gateway identity token..." + # Fixed, not read from the environment: the workbench .env is sourced above, + # and an audience it could set is one the mint would refuse. + AUDIENCE="posthog-wizard-ci" + IDENTITY_TOKEN=$(curl -sS --fail-with-body \ + -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r '.value') || IDENTITY_TOKEN="" + if [ -z "$IDENTITY_TOKEN" ] || [ "$IDENTITY_TOKEN" = "null" ]; then + echo "::error::could not obtain a GitHub identity token" + exit 1 + fi + echo "::add-mask::$IDENTITY_TOKEN" + export POSTHOG_WIZARD_GATEWAY_TOKEN="$IDENTITY_TOKEN" + unset IDENTITY_TOKEN +elif [ "${GITHUB_ACTIONS:-}" = "true" ]; then + # An unset pair means the job was not granted id-token: write. Without it the + # mint refuses and the run would quietly spend on the legacy path instead. + echo "::error::id-token: write is not granted to this job" + exit 1 +fi +# The wizard runs model-written code below. Holding these is permission to ask +# GitHub for a token naming any audience, so drop them either way. +unset ACTIONS_ID_TOKEN_REQUEST_URL ACTIONS_ID_TOKEN_REQUEST_TOKEN + # ── Run wizard in CI mode ─────────────────────────────────────────────────── echo "==> Running wizard in CI mode..." echo " App: $APP" diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 0fff9a65..d34aa6a3 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -862,9 +862,48 @@ describe('gatewayAuth with a CI identity token', () => { ); }); - it('keeps the identity token out of the legacy fallback', async () => { - // Only the mint can verify it. The legacy gateway would read it as a - // credential, and it is not one. + it('fails the run rather than falling back when the mint refuses', async () => { + // The refusal a broken identity path produces is a 401, which the CI + // fallback admits. Falling back would pass the smoke test on the gateway + // this change exists to stop using. + setLegacyGatewayFallback(true); + try { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); + + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintRefused); + } finally { + setLegacyGatewayFallback(false); + } + }); + + it('still lets a user run fall back on the same refusal', async () => { + // The other arm: only the CI identity forfeits the fallback. + vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', ''); + setLegacyGatewayFallback(true); + try { + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }); + + const auth = await gatewayAuth(host, 'phx_personal', 'integration'); + expect(auth).toMatchObject({ token: 'phx_personal', legacy: true }); + } finally { + setLegacyGatewayFallback(false); + } + }); + + it('treats a whitespace-only variable as absent', async () => { + // Pins the trim: an unset-but-present variable must not forfeit the + // fallback that a user run still has. + vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', ' '); setLegacyGatewayFallback(true); try { fetchMock.mockResolvedValue({ @@ -898,6 +937,15 @@ describe('gatewayAuth with a CI identity token', () => { expect(loggedLines().join('\n')).toContain('identity=ci'); }); + it('names a user run as a user run', async () => { + // The other arm: labelling every mint `ci` would be as useless as no label. + vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', ''); + fetchMock.mockResolvedValue(minted); + + await gatewayAuth(host, 'phx_personal', 'integration'); + expect(loggedLines().join('\n')).toContain('identity=user'); + }); + it('never writes the identity token to the log', async () => { fetchMock.mockResolvedValue(minted); diff --git a/src/lib/agent/__tests__/agent-env-isolation.test.ts b/src/lib/agent/__tests__/agent-env-isolation.test.ts index 8f882fac..5000e0cf 100644 --- a/src/lib/agent/__tests__/agent-env-isolation.test.ts +++ b/src/lib/agent/__tests__/agent-env-isolation.test.ts @@ -37,13 +37,19 @@ describe('isBlockedAgentEnvKey', () => { }); it('blocks the CI identity token and the means to ask for another', () => { - // The pair below is permission to request tokens for any audience the - // holder names, which is the whole of what CI proves to the mint. + // Holding the request pair is permission to ask GitHub for a token naming + // any audience, which is the whole of what CI proves to the mint. expect(isBlockedAgentEnvKey('POSTHOG_WIZARD_GATEWAY_TOKEN')).toBe(true); expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_URL')).toBe(true); expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_TOKEN')).toBe(true); }); + it('blocks the whole identity-request namespace, not the two names', () => { + expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_ANYTHING')).toBe( + true, + ); + }); + it('still passes through POSTHOG_API_KEY (deliberate, pre-existing disposition)', () => { // The agent may rely on it when writing the user's project key into the // project's own .env; changing that is a separate decision. diff --git a/src/lib/agent/agent-env-isolation.ts b/src/lib/agent/agent-env-isolation.ts index 7cd9c472..728148f5 100644 --- a/src/lib/agent/agent-env-isolation.ts +++ b/src/lib/agent/agent-env-isolation.ts @@ -33,6 +33,13 @@ */ const PROVIDER_ENV_NAMESPACE = /^(ANTHROPIC_|CLAUDE_CODE_)/; +/** + * The runner's identity-token namespace. Holding any of it is permission to ask + * GitHub for a token naming any audience, so it goes by namespace rather than by + * the two names that exist today. + */ +const CI_IDENTITY_ENV_NAMESPACE = /^ACTIONS_ID_TOKEN_REQUEST/; + /** * Off-namespace credential that the binary can use without a provider-activation * flag, so the namespace rule alone wouldn't catch it. (Bedrock ignores it once @@ -56,10 +63,9 @@ const BLOCKED_OFF_NAMESPACE_KEYS = new Set(['AWS_BEARER_TOKEN_BEDROCK']); * read by the wizard's analytics only; they let the agent fingerprint the * run directory where the handoff path typically sits. * - * - `POSTHOG_WIZARD_GATEWAY_TOKEN` and the two `ACTIONS_ID_TOKEN_REQUEST_*` - * values are CI identity: the first is the bearer the mint verifies, the pair - * lets a holder ask GitHub for more of them, for any audience it names. Only - * the wizard process itself mints, so the agent never needs either. + * - `POSTHOG_WIZARD_GATEWAY_TOKEN` is the bearer the wizard's own mint call + * verifies. Only the wizard process mints, so the agent never needs it. The + * request variables that produce it are stripped by namespace above. * * Deliberately NOT stripped: `POSTHOG_API_KEY` / `POSTHOG_HOST` — pre-existing * passthrough that the agent may rely on when writing the user's project key @@ -71,8 +77,6 @@ const HOST_ONLY_ENV_KEYS = new Set([ 'POSTHOG_TASK_RUN_ID', 'POSTHOG_TASK_ID', 'POSTHOG_WIZARD_GATEWAY_TOKEN', - 'ACTIONS_ID_TOKEN_REQUEST_URL', - 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', ]); /** @@ -160,6 +164,7 @@ export const BLOCKED_AGENT_ENV_PATTERNS: readonly RegExp[] = [ export function isBlockedAgentEnvKey(key: string): boolean { return ( PROVIDER_ENV_NAMESPACE.test(key) || + CI_IDENTITY_ENV_NAMESPACE.test(key) || BLOCKED_OFF_NAMESPACE_KEYS.has(key) || HOST_ONLY_ENV_KEYS.has(key) ); diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index dadb5967..da94437d 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -65,8 +65,11 @@ const MAX_REFUSAL_OUTCOME_LENGTH = 64; * mint accepts, so it never becomes the run's gateway credential and the legacy * fallback keeps using the user's own token. */ -function mintBearer(accessToken: string): string { - return process.env.POSTHOG_WIZARD_GATEWAY_TOKEN?.trim() || accessToken; +function mintBearer(accessToken: string): { bearer: string; ci: boolean } { + const identity = process.env.POSTHOG_WIZARD_GATEWAY_TOKEN?.trim(); + return identity + ? { bearer: identity, ci: true } + : { bearer: accessToken, ci: false }; } /** Resolve this run's gateway auth, minting and re-minting near expiry. */ @@ -75,7 +78,7 @@ export async function gatewayAuth( accessToken: string, program: string | undefined, ): Promise { - const bearer = mintBearer(accessToken); + const { bearer, ci } = mintBearer(accessToken); // Keyed by program: a token pins `wizard:`, so reusing one across // programs bills the wrong budget. const key = `${host.apiHost}\n${bearer}\n${program ?? ''}`; @@ -83,7 +86,14 @@ export async function gatewayAuth( return cached.auth; } if (inFlight && inFlight.key === key) return inFlight.promise; - const promise = resolveGatewayAuth(host, accessToken, bearer, key, program); + const promise = resolveGatewayAuth( + host, + accessToken, + bearer, + ci, + key, + program, + ); inFlight = { key, promise }; try { return await promise; @@ -96,6 +106,7 @@ async function resolveGatewayAuth( host: HostResolution, accessToken: string, bearer: string, + ci: boolean, key: string, program: string | undefined, ): Promise { @@ -112,7 +123,10 @@ async function resolveGatewayAuth( minted = await mintGatewayToken(host, bearer, program); } catch (e) { if (!(e instanceof GatewayMintRefused)) throw e; - const legacy = legacyGatewayAuth(host, accessToken, e.status); + // A CI run that cannot mint has to fail. Falling back would leave a broken + // identity path behind a green smoke test, spending on the very gateway this + // exists to stop using. + const legacy = ci ? null : legacyGatewayAuth(host, accessToken, e.status); if (!legacy) throw e; logToFile( `[gateway] mint refused this credential (HTTP ${e.status}); CI run staying on the legacy gateway`, @@ -139,7 +153,7 @@ async function resolveGatewayAuth( `[gateway] minted a scoped token: program=${program} team=${ minted.teamId ?? 'unknown' } ttl=${Math.round(ttlMs / 1000)}s url=${minted.gatewayUrl} identity=${ - bearer === accessToken ? 'user' : 'ci' + ci ? 'ci' : 'user' }`, ); const auth: GatewayAuth = { From 702d87a88719078c3a6ad5b1b4fe7327b335f8af Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Thu, 10 Sep 2026 08:32:52 -0400 Subject: [PATCH 4/6] chore(ci): correct a comment the fail-closed change made false The missing-permission branch said the run would quietly spend on the legacy path. A CI run that cannot mint now fails instead, so the comment names what failing there buys rather than what it prevents. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/smoke-test.yml | 5 ++--- scripts/smoke-test-ci.sh | 11 +++++------ src/lib/gateway-session.ts | 5 ++--- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 9c261751..428f77cb 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -73,9 +73,8 @@ jobs: POSTHOG_WIZARD_REGION: us WIZARD_WORKBENCH_ROOT: ${{ github.workspace }}/wizard-workbench SMOKE_TEST_APP: ${{ inputs.app || 'basic-integration/next-js/15-app-router-todo' }} - # The script asks GitHub for the identity token itself, immediately before - # the run that spends it, and drops the request variables before the - # wizard starts. Doing it here would age the token through the build. + # The script requests the identity token itself, so it is seconds old at + # the mint, and drops the request variables before the wizard starts. run: ./scripts/smoke-test-ci.sh "$SMOKE_TEST_APP" - name: Upload artifacts diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh index 5adee9eb..a61e8594 100755 --- a/scripts/smoke-test-ci.sh +++ b/scripts/smoke-test-ci.sh @@ -132,12 +132,11 @@ if [ ! -f "$WIZARD_BIN" ]; then fi # ── Gateway identity, for CI only ─────────────────────────────────────────── -# Requested here rather than in the workflow because the build and installs -# above take minutes and the token is short-lived. Seconds old at the mint. +# The build and installs above take minutes and the token is short-lived, so it +# is requested here: seconds old when the mint reads it. if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] && [ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then echo "==> Requesting a gateway identity token..." - # Fixed, not read from the environment: the workbench .env is sourced above, - # and an audience it could set is one the mint would refuse. + # Fixed: the workbench .env is sourced above and could otherwise set it. AUDIENCE="posthog-wizard-ci" IDENTITY_TOKEN=$(curl -sS --fail-with-body \ -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ @@ -150,8 +149,8 @@ if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] && [ -n "${ACTIONS_ID_TOKEN_REQUES export POSTHOG_WIZARD_GATEWAY_TOKEN="$IDENTITY_TOKEN" unset IDENTITY_TOKEN elif [ "${GITHUB_ACTIONS:-}" = "true" ]; then - # An unset pair means the job was not granted id-token: write. Without it the - # mint refuses and the run would quietly spend on the legacy path instead. + # An unset pair means the job was not granted id-token: write. Failing here + # names that, rather than surfacing it as a mint refusal further along. echo "::error::id-token: write is not granted to this job" exit 1 fi diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index da94437d..a0a81a4a 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -123,9 +123,8 @@ async function resolveGatewayAuth( minted = await mintGatewayToken(host, bearer, program); } catch (e) { if (!(e instanceof GatewayMintRefused)) throw e; - // A CI run that cannot mint has to fail. Falling back would leave a broken - // identity path behind a green smoke test, spending on the very gateway this - // exists to stop using. + // A CI run that cannot mint has to fail: falling back would leave a broken + // identity path behind a green smoke test. const legacy = ci ? null : legacyGatewayAuth(host, accessToken, e.status); if (!legacy) throw e; logToFile( From 53b46b2a0b2c92e9f0d57fe0de180f02f05da0dd Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Thu, 10 Sep 2026 16:03:07 -0400 Subject: [PATCH 5/6] feat(ci): request a fresh GitHub identity token for every mint Opt in with WIZARD_CI_IDENTITY=github-actions. The request pair leaves the environment at import, and a renewal that fails for availability or is throttled keeps the live token with bounded retries. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/smoke-test.yml | 6 - scripts/smoke-test-ci.sh | 27 -- src/env.ts | 4 + src/lib/__tests__/ci-identity.test.ts | 167 +++++++++ src/lib/__tests__/gateway-session.test.ts | 342 ++++++++++++++---- .../__tests__/agent-env-isolation.test.ts | 4 +- src/lib/agent/agent-env-isolation.ts | 5 - src/lib/agent/agent-interface.ts | 2 +- src/lib/ci-identity.ts | 108 ++++++ src/lib/gateway-session.ts | 151 +++++--- 10 files changed, 651 insertions(+), 165 deletions(-) create mode 100644 src/lib/__tests__/ci-identity.test.ts create mode 100644 src/lib/ci-identity.ts diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 428f77cb..4c4ec297 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -19,10 +19,6 @@ permissions: jobs: smoke-test: - permissions: - contents: read - # Lets this job ask GitHub for the identity token the wizard mint verifies. - id-token: write name: Smoke Test (${{ inputs.app || 'basic-integration/next-js/15-app-router-todo' }}) runs-on: ubuntu-latest timeout-minutes: 15 @@ -73,8 +69,6 @@ jobs: POSTHOG_WIZARD_REGION: us WIZARD_WORKBENCH_ROOT: ${{ github.workspace }}/wizard-workbench SMOKE_TEST_APP: ${{ inputs.app || 'basic-integration/next-js/15-app-router-todo' }} - # The script requests the identity token itself, so it is seconds old at - # the mint, and drops the request variables before the wizard starts. run: ./scripts/smoke-test-ci.sh "$SMOKE_TEST_APP" - name: Upload artifacts diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh index a61e8594..80131234 100755 --- a/scripts/smoke-test-ci.sh +++ b/scripts/smoke-test-ci.sh @@ -131,33 +131,6 @@ if [ ! -f "$WIZARD_BIN" ]; then exit 1 fi -# ── Gateway identity, for CI only ─────────────────────────────────────────── -# The build and installs above take minutes and the token is short-lived, so it -# is requested here: seconds old when the mint reads it. -if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] && [ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then - echo "==> Requesting a gateway identity token..." - # Fixed: the workbench .env is sourced above and could otherwise set it. - AUDIENCE="posthog-wizard-ci" - IDENTITY_TOKEN=$(curl -sS --fail-with-body \ - -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r '.value') || IDENTITY_TOKEN="" - if [ -z "$IDENTITY_TOKEN" ] || [ "$IDENTITY_TOKEN" = "null" ]; then - echo "::error::could not obtain a GitHub identity token" - exit 1 - fi - echo "::add-mask::$IDENTITY_TOKEN" - export POSTHOG_WIZARD_GATEWAY_TOKEN="$IDENTITY_TOKEN" - unset IDENTITY_TOKEN -elif [ "${GITHUB_ACTIONS:-}" = "true" ]; then - # An unset pair means the job was not granted id-token: write. Failing here - # names that, rather than surfacing it as a mint refusal further along. - echo "::error::id-token: write is not granted to this job" - exit 1 -fi -# The wizard runs model-written code below. Holding these is permission to ask -# GitHub for a token naming any audience, so drop them either way. -unset ACTIONS_ID_TOKEN_REQUEST_URL ACTIONS_ID_TOKEN_REQUEST_TOKEN - # ── Run wizard in CI mode ─────────────────────────────────────────────────── echo "==> Running wizard in CI mode..." echo " App: $APP" diff --git a/src/env.ts b/src/env.ts index d13b223d..e31786f9 100644 --- a/src/env.ts +++ b/src/env.ts @@ -53,6 +53,10 @@ type RuntimeEnvKey = // would claim it as an unknown CLI option and strict-reject the run. | 'WIZARD_CI_FLAG_OVERRIDES' | 'WIZARD_CI_EXCLUDE_TASKS' + // CI identity opt-in and the runner's identity-request pair (lib/ci-identity.ts). + | 'WIZARD_CI_IDENTITY' + | 'ACTIONS_ID_TOKEN_REQUEST_URL' + | 'ACTIONS_ID_TOKEN_REQUEST_TOKEN' // Wizard CLI configuration (yargs POSTHOG_WIZARD_ prefix) | 'POSTHOG_WIZARD_BENCHMARK_CONFIG' | 'POSTHOG_WIZARD_BENCHMARK_FILE' diff --git a/src/lib/__tests__/ci-identity.test.ts b/src/lib/__tests__/ci-identity.test.ts new file mode 100644 index 00000000..6adf92bd --- /dev/null +++ b/src/lib/__tests__/ci-identity.test.ts @@ -0,0 +1,167 @@ +import { + CiIdentityUnavailable, + captureCiIdentityRequest, + ciIdentityMode, + requestCiIdentityToken, + resetCiIdentity, + usesCiIdentity, +} from '@lib/ci-identity'; + +const REQUEST_URL = + 'https://run-actions-1-azure-eastus.actions.githubusercontent.com/abc/idtoken?api-version=2.0'; + +describe('CI identity', () => { + const fetchMock = vi.fn(); + const issued = (value: unknown = 'header.payload.signature') => ({ + ok: true, + status: 200, + json: () => Promise.resolve({ value }), + }); + + beforeEach(() => { + resetCiIdentity(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('WIZARD_CI_IDENTITY', 'github-actions'); + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_URL', REQUEST_URL); + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', 'runner-request-token'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('is off unless the run opts in with the exact value', () => { + vi.stubEnv('WIZARD_CI_IDENTITY', 'true'); + expect(usesCiIdentity()).toBe(false); + }); + + it('reports an unknown opt-in value as unknown, and an empty one as off', () => { + vi.stubEnv('WIZARD_CI_IDENTITY', 'github'); + expect(ciIdentityMode()).toBe('unknown'); + vi.stubEnv('WIZARD_CI_IDENTITY', ''); + expect(ciIdentityMode()).toBe('off'); + }); + + it('takes the request pair out of the environment when the module loads', async () => { + vi.resetModules(); + await import('@lib/ci-identity'); + expect(process.env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); + }); + + it('takes the request pair out of the environment at capture', () => { + captureCiIdentityRequest(); + expect(process.env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); + }); + + it('leaves the environment alone when the run does not opt in', () => { + vi.stubEnv('WIZARD_CI_IDENTITY', ''); + captureCiIdentityRequest(); + expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe( + 'runner-request-token', + ); + }); + + it('asks GitHub for the mint audience with the request token', async () => { + fetchMock.mockResolvedValue(issued()); + await expect(requestCiIdentityToken()).resolves.toBe( + 'header.payload.signature', + ); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe(`${REQUEST_URL}&audience=posthog-wizard-ci`); + expect(init).toMatchObject({ + headers: { Authorization: 'bearer runner-request-token' }, + redirect: 'error', + }); + }); + + it('asks again for every mint, after the pair has left the environment', async () => { + fetchMock.mockResolvedValue(issued()); + await requestCiIdentityToken(); + await requestCiIdentityToken(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['another host', 'https://evil.example/idtoken?api-version=2.0'], + [ + 'a lookalike host', + 'https://actions.githubusercontent.com.evil.example/idtoken', + ], + [ + 'a lookalike host with a label before GitHub', + 'https://run.actions.githubusercontent.com.evil.example/idtoken', + ], + [ + 'plain http', + 'http://run-actions-1-azure-eastus.actions.githubusercontent.com/idtoken', + ], + ['something that is not a URL', 'not a url'], + ])('never sends the request token to %s', async (_, url) => { + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_URL', url); + await expect(requestCiIdentityToken()).rejects.toBeInstanceOf( + CiIdentityUnavailable, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fails when the job was not granted id-token: write', async () => { + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', ''); + await expect(requestCiIdentityToken()).rejects.toBeInstanceOf( + CiIdentityUnavailable, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['a refusal', { ok: false, status: 403, json: () => Promise.resolve({}) }], + ['a response with no token', issued(null)], + [ + 'a body that is not JSON', + { + ok: true, + status: 200, + json: () => Promise.reject(new SyntaxError('bad')), + }, + ], + ])('fails on %s', async (_, response) => { + fetchMock.mockResolvedValue(response); + await expect(requestCiIdentityToken()).rejects.toBeInstanceOf( + CiIdentityUnavailable, + ); + }); + + it('bounds the identity request with a ten second timeout', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout'); + try { + fetchMock.mockResolvedValue(issued()); + await requestCiIdentityToken(); + expect(timeout).toHaveBeenCalledWith(10_000); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + signal: timeout.mock.results[0].value, + }); + } finally { + timeout.mockRestore(); + } + }); + + it('fails when GitHub does not answer', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed')); + await expect(requestCiIdentityToken()).rejects.toBeInstanceOf( + CiIdentityUnavailable, + ); + }); + + it('never puts the request token in an error', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.resolve({}), + }); + const error = await requestCiIdentityToken().catch((e: unknown) => e); + expect((error as Error).message).not.toContain('runner-request-token'); + }); +}); diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index d34aa6a3..2de264d7 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -11,6 +11,7 @@ import { import type { HostResolution } from '@lib/host-resolution'; import { ErrorCodes } from '@lib/errors'; import { setLegacyGatewayFallback } from '@lib/legacy-gateway'; +import { resetCiIdentity } from '@lib/ci-identity'; import { WizardError } from '@utils/wizard-abort'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; @@ -325,7 +326,7 @@ describe('gatewayAuth', () => { ); expect(analytics.wizardCapture).toHaveBeenCalledWith( 'gateway mint refused', - { status: 403, outcome: 'blocked', program: 'audit' }, + { status: 403, outcome: 'blocked', program: 'audit', renewal: false }, ); }); @@ -380,7 +381,7 @@ describe('gatewayAuth', () => { expect(analytics.wizardCapture).toHaveBeenCalledTimes(1); expect(analytics.wizardCapture).toHaveBeenCalledWith( 'gateway mint refused', - { status: 403, outcome: 'blocked', program: 'audit' }, + { status: 403, outcome: 'blocked', program: 'audit', renewal: false }, ); expect((err as GatewayMintRefused).outcome).toBe('blocked'); }); @@ -400,7 +401,12 @@ describe('gatewayAuth', () => { ).rejects.toBeInstanceOf(GatewayMintRefused); expect(analytics.wizardCapture).toHaveBeenCalledWith( 'gateway mint refused', - { status: 429, outcome: undefined, program: 'integration' }, + { + status: 429, + outcome: undefined, + program: 'integration', + renewal: false, + }, ); }, ); @@ -822,58 +828,97 @@ describe('isTrustedGatewayUrl', () => { }); }); -describe('gatewayAuth with a CI identity token', () => { +describe('gatewayAuth with a CI identity', () => { const fetchMock = vi.fn(); - const minted = { + const MINT_URL = 'https://us.posthog.com/api/wizard/gateway_token/'; + const REQUEST_URL = + 'https://run-actions-1-azure-eastus.actions.githubusercontent.com/abc/idtoken?api-version=2.0'; + let issued = 0; + + const minted = (ttlMs = 3600_000) => ({ ok: true, json: () => Promise.resolve({ token: 'phe_ci', - expires_at: new Date(Date.now() + 3600_000).toISOString(), + expires_at: new Date(Date.now() + ttlMs).toISOString(), gateway_url: 'https://ai-gateway.us.posthog.com', }), + }); + const refused = { ok: false, status: 401, json: () => Promise.resolve({}) }; + const throttled = { ok: false, status: 429, json: () => Promise.resolve({}) }; + const unavailable = { + ok: false, + status: 503, + json: () => Promise.resolve({}), }; + // GitHub answers the identity request with a new token each time; the mint + // answers with whatever `mint` returns. + const route = (mint: () => unknown) => + fetchMock.mockImplementation((url: URL | string) => + Promise.resolve( + String(url).startsWith(REQUEST_URL) + ? { + ok: true, + status: 200, + json: () => + Promise.resolve({ value: `identity.token.${++issued}` }), + } + : mint(), + ), + ); + const mintBearers = () => + fetchMock.mock.calls + .filter(([url]) => String(url) === MINT_URL) + .map( + ([, init]) => + (init as { headers: Record }).headers.Authorization, + ); beforeEach(() => { + issued = 0; resetGatewaySession(); + resetCiIdentity(); fetchMock.mockReset(); vi.mocked(logToFile).mockClear(); vi.stubGlobal('fetch', fetchMock); - vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', 'header.payload.signature'); + vi.stubEnv('WIZARD_CI_IDENTITY', 'github-actions'); + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_URL', REQUEST_URL); + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', 'runner-request-token'); }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); - it('mints with the identity token rather than the personal key', async () => { - fetchMock.mockResolvedValue(minted); - + it('mints with a GitHub identity token rather than the personal key', async () => { + route(() => minted()); const auth = await gatewayAuth(host, 'phx_personal', 'integration'); expect(auth.token).toBe('phe_ci'); - expect(fetchMock).toHaveBeenCalledWith( - 'https://us.posthog.com/api/wizard/gateway_token/', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer header.payload.signature', - }), - }), - ); + expect(mintBearers()).toEqual(['Bearer identity.token.1']); + }); + + it('asks GitHub for a new identity token when it re-mints', async () => { + // Identity tokens are single-use and expire in minutes, so a re-mint that + // reused the first would be refused. + vi.useFakeTimers({ toFake: ['Date'] }); + route(() => minted(150_000)); + await gatewayAuth(host, 'phx_personal', 'integration'); + vi.setSystemTime(Date.now() + 130_000); + await gatewayAuth(host, 'phx_personal', 'integration'); + expect(mintBearers()).toEqual([ + 'Bearer identity.token.1', + 'Bearer identity.token.2', + ]); }); it('fails the run rather than falling back when the mint refuses', async () => { // The refusal a broken identity path produces is a 401, which the CI - // fallback admits. Falling back would pass the smoke test on the gateway - // this change exists to stop using. + // fallback admits for a personal key. setLegacyGatewayFallback(true); try { - fetchMock.mockResolvedValue({ - ok: false, - status: 401, - json: () => Promise.resolve({}), - }); - + route(() => refused); await expect( gatewayAuth(host, 'phx_personal', 'integration'), ).rejects.toBeInstanceOf(GatewayMintRefused); @@ -882,89 +927,234 @@ describe('gatewayAuth with a CI identity token', () => { } }); - it('still lets a user run fall back on the same refusal', async () => { - // The other arm: only the CI identity forfeits the fallback. - vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', ''); + it('fails the run without minting when GitHub gives no identity token', async () => { setLegacyGatewayFallback(true); try { - fetchMock.mockResolvedValue({ - ok: false, - status: 401, - json: () => Promise.resolve({}), - }); - - const auth = await gatewayAuth(host, 'phx_personal', 'integration'); - expect(auth).toMatchObject({ token: 'phx_personal', legacy: true }); + vi.stubEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', ''); + route(() => minted()); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintFailed); + expect(mintBearers()).toEqual([]); } finally { setLegacyGatewayFallback(false); } }); - it('treats a whitespace-only variable as absent', async () => { - // Pins the trim: an unset-but-present variable must not forfeit the - // fallback that a user run still has. - vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', ' '); + it('still lets a user run fall back on the same refusal', async () => { + vi.stubEnv('WIZARD_CI_IDENTITY', ''); setLegacyGatewayFallback(true); try { - fetchMock.mockResolvedValue({ - ok: false, - status: 401, - json: () => Promise.resolve({}), - }); - + route(() => refused); const auth = await gatewayAuth(host, 'phx_personal', 'integration'); expect(auth).toMatchObject({ token: 'phx_personal', legacy: true }); + expect(mintBearers()).toEqual(['Bearer phx_personal']); } finally { setLegacyGatewayFallback(false); } }); - it('mints again when the identity token changes', async () => { - // The session cache keys on the bearer actually sent. Keyed on the personal - // key instead, a second run would serve the first run's token. - fetchMock.mockResolvedValue(minted); - + it('names the identity that minted, so a CI run is separable in the log', async () => { + route(() => minted()); await gatewayAuth(host, 'phx_personal', 'integration'); - vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', 'second.identity.token'); + expect(loggedLines().join('\n')).toContain('identity=ci'); + }); + + it('names a user run as a user run', async () => { + vi.stubEnv('WIZARD_CI_IDENTITY', ''); + route(() => minted()); await gatewayAuth(host, 'phx_personal', 'integration'); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(loggedLines().join('\n')).toContain('identity=user'); }); - it('names the identity that minted, so a CI run is separable in the log', async () => { - fetchMock.mockResolvedValue(minted); + it('keeps a live token when a renewal fails for availability, then renews', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + let available = true; + route(() => + available + ? minted(150_000) + : { ok: false, status: 503, json: () => Promise.resolve({}) }, + ); + const first = await gatewayAuth(host, 'phx_personal', 'integration'); + available = false; + vi.setSystemTime(start + 130_000); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).resolves.toBe(first); + available = true; + vi.setSystemTime(start + 151_000); + const renewed = await gatewayAuth(host, 'phx_personal', 'integration'); + expect(renewed).not.toBe(first); + expect(mintBearers()).toHaveLength(3); + }); - await gatewayAuth(host, 'phx_personal', 'integration'); - expect(loggedLines().join('\n')).toContain('identity=ci'); + it('gives callers that join a failing renewal the live token too', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + let available = true; + route(() => (available ? minted(150_000) : unavailable)); + const first = await gatewayAuth(host, 'phx_personal', 'integration'); + available = false; + vi.setSystemTime(start + 130_000); + const joined = await Promise.all([ + gatewayAuth(host, 'phx_personal', 'integration'), + gatewayAuth(host, 'phx_personal', 'integration'), + ]); + expect(joined).toEqual([first, first]); + expect(mintBearers()).toHaveLength(2); }); - it('names a user run as a user run', async () => { - // The other arm: labelling every mint `ci` would be as useless as no label. - vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', ''); - fetchMock.mockResolvedValue(minted); + it("never answers a failed mint for one program with another program's token", async () => { + let available = true; + route(() => (available ? minted() : unavailable)); + await gatewayAuth(host, 'phx_personal', 'integration'); + available = false; + await expect( + gatewayAuth(host, 'phx_personal', 'warehouse'), + ).rejects.toThrow(); + }); + it('stops serving a kept token once it expires', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + let available = true; + route(() => (available ? minted(150_000) : unavailable)); await gatewayAuth(host, 'phx_personal', 'integration'); - expect(loggedLines().join('\n')).toContain('identity=user'); + available = false; + vi.setSystemTime(start + 151_000); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).rejects.toThrow(); }); - it('never writes the identity token to the log', async () => { - fetchMock.mockResolvedValue(minted); + it('doubles the wait between failed renewals and stops after three retries', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + let available = true; + route(() => (available ? minted(7_200_000) : unavailable)); + const first = await gatewayAuth(host, 'phx_personal', 'integration'); + available = false; + const at = async (seconds: number, mints: number) => { + vi.setSystemTime(start + seconds * 1000); + const auth = await gatewayAuth(host, 'phx_personal', 'integration'); + expect(mintBearers()).toHaveLength(mints); + return auth; + }; + // Stale at 5760s. The waits are 60s, 120s and 240s, probed a second either side. + await at(5800, 2); + await at(5859, 2); + await at(5860, 3); + await at(5979, 3); + await at(5980, 4); + await at(6219, 4); + await at(6220, 5); + await expect(at(7199, 5)).resolves.toBe(first); + vi.setSystemTime(start + 7_200_000); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).rejects.toThrow(); + expect(mintBearers()).toHaveLength(6); + }); + it('starts the retry count again after a renewal succeeds', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + let available = true; + route(() => (available ? minted(7_200_000) : unavailable)); await gatewayAuth(host, 'phx_personal', 'integration'); - expect(loggedLines().join('\n')).not.toContain('header.payload.signature'); + const at = (seconds: number) => { + vi.setSystemTime(start + seconds * 1000); + return gatewayAuth(host, 'phx_personal', 'integration'); + }; + available = false; + await at(5800); + available = true; + const renewed = await at(5860); + available = false; + // The renewed token is stale at 11620s, and its first failure waits 60s again. + await at(11620); + await expect(at(11679)).resolves.toBe(renewed); + expect(mintBearers()).toHaveLength(4); + await at(11680); + expect(mintBearers()).toHaveLength(5); }); - it('falls back to the personal key when the variable is blank', async () => { - vi.stubEnv('POSTHOG_WIZARD_GATEWAY_TOKEN', ' '); - fetchMock.mockResolvedValue(minted); + it('keeps a live token when a renewal is throttled', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + let throttle = false; + route(() => (throttle ? throttled : minted(150_000))); + const first = await gatewayAuth(host, 'phx_personal', 'integration'); + throttle = true; + vi.setSystemTime(start + 130_000); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).resolves.toBe(first); + }); + it('marks a throttled renewal in the refusal event, since the run goes on', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + let throttle = false; + route(() => (throttle ? throttled : minted(150_000))); await gatewayAuth(host, 'phx_personal', 'integration'); - expect(fetchMock).toHaveBeenCalledWith( - 'https://us.posthog.com/api/wizard/gateway_token/', - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer phx_personal', - }), - }), + vi.mocked(analytics.wizardCapture).mockClear(); + throttle = true; + vi.setSystemTime(start + 130_000); + await gatewayAuth(host, 'phx_personal', 'integration'); + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'gateway mint refused', + { + status: 429, + outcome: undefined, + program: 'integration', + renewal: true, + }, + ); + }); + + it.each([400, 401, 403, 404])( + 'still ends the run when a renewal is refused with %i', + async (status) => { + vi.useFakeTimers({ toFake: ['Date'] }); + const start = Date.now(); + let refuse = false; + route(() => + refuse + ? { ok: false, status, json: () => Promise.resolve({}) } + : minted(150_000), + ); + await gatewayAuth(host, 'phx_personal', 'integration'); + refuse = true; + vi.setSystemTime(start + 130_000); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintRefused); + }, + ); + + it('fails the run on an unknown opt-in value instead of using the personal key', async () => { + vi.stubEnv('WIZARD_CI_IDENTITY', 'github'); + setLegacyGatewayFallback(true); + try { + route(() => refused); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintFailed); + expect(mintBearers()).toEqual([]); + } finally { + setLegacyGatewayFallback(false); + } + }); + + it('never writes the identity token or the request token to the log', async () => { + route(() => refused); + await gatewayAuth(host, 'phx_personal', 'integration').catch( + () => undefined, ); + const logged = loggedLines().join('\n'); + expect(logged).not.toContain('identity.token'); + expect(logged).not.toContain('runner-request-token'); }); }); diff --git a/src/lib/agent/__tests__/agent-env-isolation.test.ts b/src/lib/agent/__tests__/agent-env-isolation.test.ts index 5000e0cf..6a4021de 100644 --- a/src/lib/agent/__tests__/agent-env-isolation.test.ts +++ b/src/lib/agent/__tests__/agent-env-isolation.test.ts @@ -36,10 +36,9 @@ describe('isBlockedAgentEnvKey', () => { expect(isBlockedAgentEnvKey('POSTHOG_TASK_ID')).toBe(true); }); - it('blocks the CI identity token and the means to ask for another', () => { + it('blocks the means to ask GitHub for an identity token', () => { // Holding the request pair is permission to ask GitHub for a token naming // any audience, which is the whole of what CI proves to the mint. - expect(isBlockedAgentEnvKey('POSTHOG_WIZARD_GATEWAY_TOKEN')).toBe(true); expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_URL')).toBe(true); expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_TOKEN')).toBe(true); }); @@ -149,7 +148,6 @@ describe('sanitizeAgentSubprocessEnv', () => { POSTHOG_HANDOFF_OUTPUT_PATH: '/run/task-42/handoff.md', POSTHOG_TASK_RUN_ID: 'task-42', POSTHOG_TASK_ID: '019abc', - POSTHOG_WIZARD_GATEWAY_TOKEN: 'header.payload.signature', ACTIONS_ID_TOKEN_REQUEST_URL: 'https://pipelines.example/token', ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'runner-request-token', // — user-facing PostHog config the agent may need for the project .env diff --git a/src/lib/agent/agent-env-isolation.ts b/src/lib/agent/agent-env-isolation.ts index 728148f5..334f3acd 100644 --- a/src/lib/agent/agent-env-isolation.ts +++ b/src/lib/agent/agent-env-isolation.ts @@ -63,10 +63,6 @@ const BLOCKED_OFF_NAMESPACE_KEYS = new Set(['AWS_BEARER_TOKEN_BEDROCK']); * read by the wizard's analytics only; they let the agent fingerprint the * run directory where the handoff path typically sits. * - * - `POSTHOG_WIZARD_GATEWAY_TOKEN` is the bearer the wizard's own mint call - * verifies. Only the wizard process mints, so the agent never needs it. The - * request variables that produce it are stripped by namespace above. - * * Deliberately NOT stripped: `POSTHOG_API_KEY` / `POSTHOG_HOST` — pre-existing * passthrough that the agent may rely on when writing the user's project key * into the project's own .env. Changing that disposition is a separate, @@ -76,7 +72,6 @@ const HOST_ONLY_ENV_KEYS = new Set([ 'POSTHOG_HANDOFF_OUTPUT_PATH', 'POSTHOG_TASK_RUN_ID', 'POSTHOG_TASK_ID', - 'POSTHOG_WIZARD_GATEWAY_TOKEN', ]); /** diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index feff69d1..9234bb4f 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -1314,7 +1314,7 @@ export async function runAgent( signals.forgetApiErrors(); spinner.message('Renewing the gateway token...'); const stale = agentConfig.gatewayAuth; - // A refusal or failure here ends the run with its own message. + // A refusal ends the run here; a failed renewal can hand back the same token. agentConfig.gatewayAuth = await refreshGatewayAuth(); logToFile( `Gateway token renewed after a 401 (${Math.round( diff --git a/src/lib/ci-identity.ts b/src/lib/ci-identity.ts new file mode 100644 index 00000000..efbc7348 --- /dev/null +++ b/src/lib/ci-identity.ts @@ -0,0 +1,108 @@ +/** + * GitHub Actions identity for CI runs. A run opted in with WIZARD_CI_IDENTITY + * asks GitHub for a fresh token before each mint: the tokens are single-use and + * expire in minutes, and a long run mints more than once. + */ + +import { runtimeEnv } from '@env'; + +const OPT_IN = 'github-actions'; +/** Fixed: an audience taken from the environment is one the mint would refuse. */ +const AUDIENCE = 'posthog-wizard-ci'; +/** The request token can name any audience, so it only ever goes to GitHub. */ +const GITHUB_TOKEN_HOST_SUFFIX = '.actions.githubusercontent.com'; +const REQUEST_TIMEOUT_MS = 10_000; + +export class CiIdentityUnavailable extends Error { + constructor(message: string) { + super(message); + this.name = 'CiIdentityUnavailable'; + } +} + +/** Undefined until captured; null when the job holds no request pair. */ +let captured: { url: string; token: string } | null | undefined; + +/** The run's opt-in; an unknown value fails the run instead of using the user's credential. */ +export function ciIdentityMode(): 'github-actions' | 'off' | 'unknown' { + const value = runtimeEnv('WIZARD_CI_IDENTITY'); + if (!value) return 'off'; + return value === OPT_IN ? 'github-actions' : 'unknown'; +} + +export function usesCiIdentity(): boolean { + return ciIdentityMode() === 'github-actions'; +} + +/** + * Moves the runner's identity-request pair out of the environment, so no process the + * wizard starts inherits it. Hygiene, not a boundary: same-user code can still read a + * parent's start-up environment, so the mint's limits are what bound the pair. + */ +export function captureCiIdentityRequest(): void { + if (captured !== undefined || !usesCiIdentity()) return; + const url = runtimeEnv('ACTIONS_ID_TOKEN_REQUEST_URL'); + const token = runtimeEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN'); + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + captured = url && token ? { url, token } : null; +} + +/** A fresh identity token for one mint. */ +export async function requestCiIdentityToken(): Promise { + captureCiIdentityRequest(); + if (!captured) { + throw new CiIdentityUnavailable( + 'this job cannot ask GitHub for an identity token; grant it id-token: write', + ); + } + let url: URL; + try { + url = new URL(captured.url); + } catch { + throw new CiIdentityUnavailable('the identity request URL is not a URL'); + } + if ( + url.protocol !== 'https:' || + !url.hostname.endsWith(GITHUB_TOKEN_HOST_SUFFIX) + ) { + throw new CiIdentityUnavailable( + `the identity request URL is not GitHub's (${url.hostname})`, + ); + } + url.searchParams.set('audience', AUDIENCE); + let resp: Response; + try { + resp = await fetch(url, { + headers: { Authorization: `bearer ${captured.token}` }, + // A followed redirect would carry the request token to another host. + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new CiIdentityUnavailable( + 'GitHub did not answer the identity request', + ); + } + if (!resp.ok) { + throw new CiIdentityUnavailable( + `GitHub refused the identity request (HTTP ${resp.status})`, + ); + } + const body = (await resp.json().catch(() => null)) as { + value?: unknown; + } | null; + if (typeof body?.value !== 'string' || !body.value) { + throw new CiIdentityUnavailable('GitHub returned no identity token'); + } + return body.value; +} + +/** Test hook: forget the captured pair. */ +export function resetCiIdentity(): void { + captured = undefined; +} + +// At import, before any caller can start a process: every entrypoint that mints +// reaches this module through the gateway session. +captureCiIdentityRequest(); diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index a0a81a4a..d6ce0e5b 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -2,9 +2,10 @@ * Gateway auth for a wizard run: a `phe_` scoped token the backend mints, with * pinned attribution, a spend cap and an expiry. * - * Every mint failure throws, since a silent downgrade would spend uncapped, - * unattributed money to hide an outage. The CI-only exception lives in - * legacy-gateway.ts. + * A mint failure never downgrades to uncapped, unattributed spend. A first mint + * that fails throws; a renewal that fails without a refusal, or is throttled, + * keeps the current capped token for a few backed-off retries. The CI-only + * legacy exception lives in legacy-gateway.ts. */ import { logToFile } from '@utils/debug'; @@ -13,6 +14,11 @@ import { WizardError } from '@utils/wizard-abort'; import { ErrorCodes } from '@lib/errors'; import type { HostResolution } from '@lib/host-resolution'; import { legacyGatewayAuth } from '@lib/legacy-gateway'; +import { + CiIdentityUnavailable, + ciIdentityMode, + requestCiIdentityToken, +} from '@lib/ci-identity'; export interface GatewayAuth { /** Base URL for model calls (no `/v1`; transports append their route). */ @@ -36,6 +42,10 @@ interface CachedAuth { auth: GatewayAuth; /** Re-resolve once past this instant. */ staleAtMs: number; + /** The token stops working here; until then a failed renewal keeps serving it. */ + expiresAtMs: number; + /** Renewals that failed without a refusal while this token was cached. */ + failedRenewals: number; } let cached: CachedAuth | null = null; @@ -52,6 +62,13 @@ let inFlight: { key: string; promise: Promise } | null = null; const MIN_USABLE_TTL_MS = 2 * 60 * 1000; /** Re-resolve at this fraction of the token's life, leaving a usable remainder. */ const REFRESH_AT_FRACTION = 0.8; +/** The first wait after a renewal fails without a refusal; it doubles each time. */ +const RENEWAL_RETRY_MS = 60_000; +/** + * Retries per cached token. Each may have spent a CI mint slot, so past this the + * token is served unrenewed until it expires. + */ +const MAX_RENEWAL_RETRIES = 3; // Exceeds the backend's own 10s gateway timeout: a slow mint that lands after the // CLI hangs up spends a daily mint and orphans a live token. const MINT_TIMEOUT_MS = 20_000; @@ -61,15 +78,20 @@ const MAX_REFUSAL_DETAIL_LENGTH = 500; const MAX_REFUSAL_OUTCOME_LENGTH = 64; /** - * The bearer the mint reads. CI presents a GitHub OIDC token, which only the - * mint accepts, so it never becomes the run's gateway credential and the legacy - * fallback keeps using the user's own token. + * A fresh GitHub identity token for one mint. Only the mint reads it, so it never + * becomes the run's gateway credential; a mint that cannot get one fails. */ -function mintBearer(accessToken: string): { bearer: string; ci: boolean } { - const identity = process.env.POSTHOG_WIZARD_GATEWAY_TOKEN?.trim(); - return identity - ? { bearer: identity, ci: true } - : { bearer: accessToken, ci: false }; +async function ciIdentityBearer(): Promise { + try { + return await requestCiIdentityToken(); + } catch (e) { + const reason = + e instanceof CiIdentityUnavailable + ? e.message + : 'the identity request failed'; + logToFile(`[gateway] no CI identity token: ${reason}`); + throw new GatewayMintFailed(`could not get a CI identity token: ${reason}`); + } } /** Resolve this run's gateway auth, minting and re-minting near expiry. */ @@ -78,21 +100,16 @@ export async function gatewayAuth( accessToken: string, program: string | undefined, ): Promise { - const { bearer, ci } = mintBearer(accessToken); // Keyed by program: a token pins `wizard:`, so reusing one across // programs bills the wrong budget. - const key = `${host.apiHost}\n${bearer}\n${program ?? ''}`; + const key = `${host.apiHost}\n${accessToken}\n${program ?? ''}`; if (cached && cached.key === key && Date.now() < cached.staleAtMs) { return cached.auth; } if (inFlight && inFlight.key === key) return inFlight.promise; - const promise = resolveGatewayAuth( - host, - accessToken, - bearer, - ci, - key, - program, + // On the shared promise, so callers that join a renewal get the same answer. + const promise = resolveGatewayAuth(host, accessToken, key, program).catch( + (e: unknown) => keepLiveToken(key, e), ); inFlight = { key, promise }; try { @@ -102,11 +119,37 @@ export async function gatewayAuth( } } +/** + * A renewal that fails without a refusal keeps a still-live token for the same key + * and retries with a doubling wait, MAX_RENEWAL_RETRIES times. A throttle is not a + * verdict on the token, so it keeps it too; any other refusal still ends the run. + */ +function keepLiveToken(key: string, e: unknown): GatewayAuth { + const live = + cached && cached.key === key && Date.now() < cached.expiresAtMs + ? cached + : null; + const refused = e instanceof GatewayMintRefused && e.status !== 429; + if (!live || refused) throw e; + live.failedRenewals += 1; + live.staleAtMs = + live.failedRenewals > MAX_RENEWAL_RETRIES + ? live.expiresAtMs + : Math.min( + Date.now() + RENEWAL_RETRY_MS * 2 ** (live.failedRenewals - 1), + live.expiresAtMs, + ); + logToFile( + `[gateway] renewal failed (${ + e instanceof Error ? e.message : 'unknown error' + }); keeping the current token`, + ); + return live.auth; +} + async function resolveGatewayAuth( host: HostResolution, accessToken: string, - bearer: string, - ci: boolean, key: string, program: string | undefined, ): Promise { @@ -118,9 +161,22 @@ async function resolveGatewayAuth( 'this run has no program to attribute its spend to', ); } + const mode = ciIdentityMode(); + if (mode === 'unknown') { + logToFile( + '[gateway] WIZARD_CI_IDENTITY has an unknown value; failing the run', + ); + throw new GatewayMintFailed( + 'WIZARD_CI_IDENTITY must be github-actions or unset', + ); + } + const ci = mode === 'github-actions'; + const renewal = + cached !== null && cached.key === key && Date.now() < cached.expiresAtMs; let minted: MintedToken; try { - minted = await mintGatewayToken(host, bearer, program); + const bearer = ci ? await ciIdentityBearer() : accessToken; + minted = await mintGatewayToken(host, bearer, program, renewal); } catch (e) { if (!(e instanceof GatewayMintRefused)) throw e; // A CI run that cannot mint has to fail: falling back would leave a broken @@ -130,7 +186,13 @@ async function resolveGatewayAuth( logToFile( `[gateway] mint refused this credential (HTTP ${e.status}); CI run staying on the legacy gateway`, ); - cached = { key, auth: legacy, staleAtMs: legacy.refreshAtMs }; + cached = { + key, + auth: legacy, + staleAtMs: legacy.refreshAtMs, + expiresAtMs: Number.POSITIVE_INFINITY, + failedRenewals: 0, + }; return legacy; } const expiresAtMs = Date.parse(minted.expiresAt); @@ -138,9 +200,7 @@ async function resolveGatewayAuth( if (!Number.isFinite(expiresAtMs) || ttlMs < MIN_USABLE_TTL_MS) { // Expired, unreadable, or too short to serve a session. Adopting it would // 401 mid-run, and downgrading would spend the rest of the run uncapped. - logToFile( - `[gateway] mint returned a token with ${ttlMs}ms of life; failing the run`, - ); + logToFile(`[gateway] mint returned a token with ${ttlMs}ms of life`); throw new GatewayMintFailed( `the PostHog gateway issued a token with ${ttlMs}ms of life`, ); @@ -161,7 +221,7 @@ async function resolveGatewayAuth( teamId: minted.teamId, refreshAtMs: staleAtMs, }; - cached = { key, auth, staleAtMs }; + cached = { key, auth, staleAtMs, expiresAtMs, failedRenewals: 0 }; return auth; } @@ -223,9 +283,10 @@ interface MintedToken { /** * A deliberate refusal from the mint endpoint, as opposed to the mint being - * unavailable. Thrown so the run stops instead of proceeding without the - * controls the refusal was enforcing. A WizardError, so the runners print its - * message as-is and `wizardAbort` resolves its code. + * unavailable. It ends the run rather than proceeding without the controls the + * refusal enforces; a throttled renewal keeps its live token instead. A + * WizardError, so the runners print its message as-is and `wizardAbort` + * resolves its code. */ export class GatewayMintRefused extends WizardError { readonly status: number; @@ -252,7 +313,7 @@ export class GatewayMintFailed extends WizardError { } /** - * Whether a mint status means "refused this run" rather than "not available". + * Whether a mint status is a refusal rather than "not available". * 429 the daily run limit, 403 revoked project access, 400 a login covering * more than one project, 401 a credential the mint does not accept, 404 an * instance without the mint endpoint. @@ -338,6 +399,7 @@ async function mintGatewayToken( host: HostResolution, bearer: string, program: string, + renewal: boolean, ): Promise { try { const resp = await fetch(`${host.apiHost}/api/wizard/gateway_token/`, { @@ -358,14 +420,15 @@ async function mintGatewayToken( logToFile( `[gateway] mint refused with HTTP ${resp.status} (${ refusal.outcome ?? 'no outcome' - }); failing the run`, + })`, ); - // The terminal denial event for this run. The backend's own event has - // no run id, so this is what joins a refusal to the session. + // The backend's own event has no run id, so this joins a refusal to the + // session. It ends the run unless it is a 429 on a renewal, which keeps the token. analytics.wizardCapture('gateway mint refused', { status: resp.status, outcome: refusal.outcome, program, + renewal, }); throw new GatewayMintRefused( resp.status, @@ -373,9 +436,7 @@ async function mintGatewayToken( refusal.outcome, ); } - logToFile( - `[gateway] mint failed with HTTP ${resp.status}; failing the run`, - ); + logToFile(`[gateway] mint failed with HTTP ${resp.status}`); throw new GatewayMintFailed( `the PostHog gateway could not issue a token (HTTP ${resp.status})`, ); @@ -389,21 +450,19 @@ async function mintGatewayToken( // Checked one at a time, not in a loop, so each clause narrows the optional // field for the return below and each names itself in the failure. if (!body.token) { - logToFile('[gateway] mint response omitted token; failing the run'); + logToFile('[gateway] mint response omitted token'); throw new GatewayMintFailed('mint response omitted token'); } if (!body.expires_at) { - logToFile('[gateway] mint response omitted expires_at; failing the run'); + logToFile('[gateway] mint response omitted expires_at'); throw new GatewayMintFailed('mint response omitted expires_at'); } if (!body.gateway_url) { - logToFile('[gateway] mint response omitted gateway_url; failing the run'); + logToFile('[gateway] mint response omitted gateway_url'); throw new GatewayMintFailed('mint response omitted gateway_url'); } if (!isTrustedGatewayUrl(body.gateway_url, host.apiHost)) { - logToFile( - '[gateway] mint returned an untrusted gateway url; failing the run', - ); + logToFile('[gateway] mint returned an untrusted gateway url'); throw new GatewayMintFailed('mint returned an untrusted gateway url'); } return { @@ -417,9 +476,7 @@ async function mintGatewayToken( // errors, and folding the others into it would lose the reason. if (e instanceof GatewayMintRefused || e instanceof GatewayMintFailed) throw e; - logToFile( - `[gateway] mint transport failure (${String(e)}); failing the run`, - ); + logToFile(`[gateway] mint transport failure (${String(e)})`); throw new GatewayMintFailed( `could not reach the PostHog gateway (${String(e)})`, ); From 0ad55f54e3acb4df10a09e7ac0350a3fd8636013 Mon Sep 17 00:00:00 2001 From: Brandon Leung Date: Thu, 10 Sep 2026 16:13:04 -0400 Subject: [PATCH 6/6] chore(ci): cut CI comments to what the code cannot say Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/__tests__/gateway-session.test.ts | 9 ++--- .../__tests__/agent-env-isolation.test.ts | 2 - src/lib/agent/agent-env-isolation.ts | 6 +-- src/lib/agent/agent-interface.ts | 2 +- src/lib/ci-identity.ts | 17 ++------ src/lib/gateway-session.ts | 39 ++++++------------- 6 files changed, 21 insertions(+), 54 deletions(-) diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 2de264d7..8a1a0dd4 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -851,8 +851,7 @@ describe('gatewayAuth with a CI identity', () => { status: 503, json: () => Promise.resolve({}), }; - // GitHub answers the identity request with a new token each time; the mint - // answers with whatever `mint` returns. + // GitHub answers each identity request with a new token. const route = (mint: () => unknown) => fetchMock.mockImplementation((url: URL | string) => Promise.resolve( @@ -900,8 +899,7 @@ describe('gatewayAuth with a CI identity', () => { }); it('asks GitHub for a new identity token when it re-mints', async () => { - // Identity tokens are single-use and expire in minutes, so a re-mint that - // reused the first would be refused. + // Identity tokens are single-use, so a re-mint that reused the first would be refused. vi.useFakeTimers({ toFake: ['Date'] }); route(() => minted(150_000)); await gatewayAuth(host, 'phx_personal', 'integration'); @@ -914,8 +912,7 @@ describe('gatewayAuth with a CI identity', () => { }); it('fails the run rather than falling back when the mint refuses', async () => { - // The refusal a broken identity path produces is a 401, which the CI - // fallback admits for a personal key. + // A broken identity path gets a 401, which the CI fallback admits for a personal key. setLegacyGatewayFallback(true); try { route(() => refused); diff --git a/src/lib/agent/__tests__/agent-env-isolation.test.ts b/src/lib/agent/__tests__/agent-env-isolation.test.ts index 6a4021de..f13edd79 100644 --- a/src/lib/agent/__tests__/agent-env-isolation.test.ts +++ b/src/lib/agent/__tests__/agent-env-isolation.test.ts @@ -37,8 +37,6 @@ describe('isBlockedAgentEnvKey', () => { }); it('blocks the means to ask GitHub for an identity token', () => { - // Holding the request pair is permission to ask GitHub for a token naming - // any audience, which is the whole of what CI proves to the mint. expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_URL')).toBe(true); expect(isBlockedAgentEnvKey('ACTIONS_ID_TOKEN_REQUEST_TOKEN')).toBe(true); }); diff --git a/src/lib/agent/agent-env-isolation.ts b/src/lib/agent/agent-env-isolation.ts index 334f3acd..82c2b432 100644 --- a/src/lib/agent/agent-env-isolation.ts +++ b/src/lib/agent/agent-env-isolation.ts @@ -33,11 +33,7 @@ */ const PROVIDER_ENV_NAMESPACE = /^(ANTHROPIC_|CLAUDE_CODE_)/; -/** - * The runner's identity-token namespace. Holding any of it is permission to ask - * GitHub for a token naming any audience, so it goes by namespace rather than by - * the two names that exist today. - */ +/** The runner's identity-token namespace, blocked whole: any of it can request a token for any audience. */ const CI_IDENTITY_ENV_NAMESPACE = /^ACTIONS_ID_TOKEN_REQUEST/; /** diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 9234bb4f..b072e819 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -1314,7 +1314,7 @@ export async function runAgent( signals.forgetApiErrors(); spinner.message('Renewing the gateway token...'); const stale = agentConfig.gatewayAuth; - // A refusal ends the run here; a failed renewal can hand back the same token. + // A throttled or failed renewal can return this same token; any other refusal ends the run. agentConfig.gatewayAuth = await refreshGatewayAuth(); logToFile( `Gateway token renewed after a 401 (${Math.round( diff --git a/src/lib/ci-identity.ts b/src/lib/ci-identity.ts index efbc7348..b60cc724 100644 --- a/src/lib/ci-identity.ts +++ b/src/lib/ci-identity.ts @@ -1,8 +1,4 @@ -/** - * GitHub Actions identity for CI runs. A run opted in with WIZARD_CI_IDENTITY - * asks GitHub for a fresh token before each mint: the tokens are single-use and - * expire in minutes, and a long run mints more than once. - */ +/** GitHub Actions identity for CI runs: a fresh single-use token from GitHub for each mint. */ import { runtimeEnv } from '@env'; @@ -23,7 +19,7 @@ export class CiIdentityUnavailable extends Error { /** Undefined until captured; null when the job holds no request pair. */ let captured: { url: string; token: string } | null | undefined; -/** The run's opt-in; an unknown value fails the run instead of using the user's credential. */ +/** The run's opt-in; an unknown value fails the run. */ export function ciIdentityMode(): 'github-actions' | 'off' | 'unknown' { const value = runtimeEnv('WIZARD_CI_IDENTITY'); if (!value) return 'off'; @@ -34,11 +30,7 @@ export function usesCiIdentity(): boolean { return ciIdentityMode() === 'github-actions'; } -/** - * Moves the runner's identity-request pair out of the environment, so no process the - * wizard starts inherits it. Hygiene, not a boundary: same-user code can still read a - * parent's start-up environment, so the mint's limits are what bound the pair. - */ +/** Moves the request pair out of process.env; hygiene only, as same-user code can still read it. */ export function captureCiIdentityRequest(): void { if (captured !== undefined || !usesCiIdentity()) return; const url = runtimeEnv('ACTIONS_ID_TOKEN_REQUEST_URL'); @@ -103,6 +95,5 @@ export function resetCiIdentity(): void { captured = undefined; } -// At import, before any caller can start a process: every entrypoint that mints -// reaches this module through the gateway session. +// At import, before any caller can start a process. captureCiIdentityRequest(); diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index d6ce0e5b..13feb953 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -2,10 +2,9 @@ * Gateway auth for a wizard run: a `phe_` scoped token the backend mints, with * pinned attribution, a spend cap and an expiry. * - * A mint failure never downgrades to uncapped, unattributed spend. A first mint - * that fails throws; a renewal that fails without a refusal, or is throttled, - * keeps the current capped token for a few backed-off retries. The CI-only - * legacy exception lives in legacy-gateway.ts. + * A mint failure never downgrades to uncapped spend: a first mint that fails + * throws, and a failed or throttled renewal keeps the capped token for a few + * retries. The CI-only legacy exception lives in legacy-gateway.ts. */ import { logToFile } from '@utils/debug'; @@ -44,7 +43,7 @@ interface CachedAuth { staleAtMs: number; /** The token stops working here; until then a failed renewal keeps serving it. */ expiresAtMs: number; - /** Renewals that failed without a refusal while this token was cached. */ + /** Failed or throttled renewals while this token was cached. */ failedRenewals: number; } @@ -62,12 +61,9 @@ let inFlight: { key: string; promise: Promise } | null = null; const MIN_USABLE_TTL_MS = 2 * 60 * 1000; /** Re-resolve at this fraction of the token's life, leaving a usable remainder. */ const REFRESH_AT_FRACTION = 0.8; -/** The first wait after a renewal fails without a refusal; it doubles each time. */ +/** The first wait after a failed or throttled renewal; it doubles each time. */ const RENEWAL_RETRY_MS = 60_000; -/** - * Retries per cached token. Each may have spent a CI mint slot, so past this the - * token is served unrenewed until it expires. - */ +/** Retries per cached token; each may spend a CI mint slot, so past this it serves until expiry. */ const MAX_RENEWAL_RETRIES = 3; // Exceeds the backend's own 10s gateway timeout: a slow mint that lands after the // CLI hangs up spends a daily mint and orphans a live token. @@ -77,10 +73,7 @@ const MAX_REFUSAL_DETAIL_LENGTH = 500; /** Outcomes are short snake_case labels; anything longer is not one. */ const MAX_REFUSAL_OUTCOME_LENGTH = 64; -/** - * A fresh GitHub identity token for one mint. Only the mint reads it, so it never - * becomes the run's gateway credential; a mint that cannot get one fails. - */ +/** A fresh GitHub identity token for one mint; it never becomes the run's gateway credential. */ async function ciIdentityBearer(): Promise { try { return await requestCiIdentityToken(); @@ -119,11 +112,7 @@ export async function gatewayAuth( } } -/** - * A renewal that fails without a refusal keeps a still-live token for the same key - * and retries with a doubling wait, MAX_RENEWAL_RETRIES times. A throttle is not a - * verdict on the token, so it keeps it too; any other refusal still ends the run. - */ +/** A failed or throttled renewal keeps the live token for its key; any other refusal ends the run. */ function keepLiveToken(key: string, e: unknown): GatewayAuth { const live = cached && cached.key === key && Date.now() < cached.expiresAtMs @@ -179,8 +168,7 @@ async function resolveGatewayAuth( minted = await mintGatewayToken(host, bearer, program, renewal); } catch (e) { if (!(e instanceof GatewayMintRefused)) throw e; - // A CI run that cannot mint has to fail: falling back would leave a broken - // identity path behind a green smoke test. + // A CI run that cannot mint fails, so a broken identity path cannot pass on the legacy gateway. const legacy = ci ? null : legacyGatewayAuth(host, accessToken, e.status); if (!legacy) throw e; logToFile( @@ -283,10 +271,8 @@ interface MintedToken { /** * A deliberate refusal from the mint endpoint, as opposed to the mint being - * unavailable. It ends the run rather than proceeding without the controls the - * refusal enforces; a throttled renewal keeps its live token instead. A - * WizardError, so the runners print its message as-is and `wizardAbort` - * resolves its code. + * unavailable. It ends the run, except a throttled renewal, which keeps its token. + * A WizardError, so the runners print its message as-is and `wizardAbort` resolves its code. */ export class GatewayMintRefused extends WizardError { readonly status: number; @@ -422,8 +408,7 @@ async function mintGatewayToken( refusal.outcome ?? 'no outcome' })`, ); - // The backend's own event has no run id, so this joins a refusal to the - // session. It ends the run unless it is a 429 on a renewal, which keeps the token. + // The backend's event has no run id, so this joins a refusal to the session. analytics.wizardCapture('gateway mint refused', { status: resp.status, outcome: refusal.outcome,