From 848118815dfb8fc0680f2032ed3bfb9598c7c72f Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Wed, 9 Sep 2026 16:50:48 -0400 Subject: [PATCH 1/2] fix(ci): use separate supplied gateway tokens Keep project API and MCP access separate from gateway access. CI uses the externally issued bearer directly and never mints or remints it. --- e2e-harness/ARCHITECTURE.md | 7 ++ scripts/tui-host.no-jest.ts | 9 +++ src/env.ts | 2 + src/lib/__tests__/gateway-session.test.ts | 80 +++++++++++++++++++++++ src/lib/gateway-session.ts | 54 ++++++++++++++- src/lib/runners/run-non-interactive.ts | 9 +++ 6 files changed, 159 insertions(+), 2 deletions(-) diff --git a/e2e-harness/ARCHITECTURE.md b/e2e-harness/ARCHITECTURE.md index 8b655e29..5b17c8d5 100644 --- a/e2e-harness/ARCHITECTURE.md +++ b/e2e-harness/ARCHITECTURE.md @@ -49,6 +49,13 @@ The direct host reads `APP_DIR`, `PROJECT_ID`, and either Detection-only MCP runs omit the key and stop at `auth`. The internal socket's `set_credentials` command is not exposed as an MCP tool. +Agent runs require `WIZARD_CI_GATEWAY_TOKEN_FILE` containing an already-issued +gateway bearer. CI uses it directly and never mints or refreshes it; missing or +rejected credentials fail the run. `WIZARD_CI_GATEWAY_URL` optionally overrides +`https://ai-gateway..posthog.com`. `POSTHOG_KEY_FILE` / +`POSTHOG_PERSONAL_API_KEY` remain separate credentials for PostHog API and MCP. +The same gateway settings apply to development `--ci` runs. + ## The two routes - **CI snapshots** — `tui-snapshots.no-jest.ts` spawns `tui-host` (`MODE=fixed`) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 7d3da90d..3a78ad0b 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -25,6 +25,7 @@ import { } from '@lib/programs/program-registry'; import type { Harness, Sequence } from '@lib/constants'; import { buildSession } from '@lib/wizard-session'; +import { configureGatewayFromCIEnvironment } from '@lib/gateway-session'; import { runAgent } from '@lib/agent/agent-runner'; import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { getOrAskForProjectData } from '@utils/setup-utils'; @@ -247,7 +248,15 @@ async function main() { // Pass the pre-run gates and run the program's real agent. The auth and run // screens never advance on their own; this is what moves them. Mirrors // run-wizard's flow, including in-program run phases. + let gatewayConfigured = false; const runProgram = async () => { + if (!gatewayConfigured) { + configureGatewayFromCIEnvironment( + Number(projectId), + store.session.region ?? 'us', + ); + gatewayConfigured = true; + } await store.getGate('intro'); await store.getGate('integration-check'); await store.getGate('health-check'); diff --git a/src/env.ts b/src/env.ts index d13b223d..1c5424c1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -53,6 +53,8 @@ type RuntimeEnvKey = // would claim it as an unknown CLI option and strict-reject the run. | 'WIZARD_CI_FLAG_OVERRIDES' | 'WIZARD_CI_EXCLUDE_TASKS' + | 'WIZARD_CI_GATEWAY_TOKEN_FILE' + | 'WIZARD_CI_GATEWAY_URL' // Wizard CLI configuration (yargs POSTHOG_WIZARD_ prefix) | 'POSTHOG_WIZARD_BENCHMARK_CONFIG' | 'POSTHOG_WIZARD_BENCHMARK_FILE' diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index 5e5213b7..228ca829 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -3,6 +3,8 @@ import { GatewayMintFailed, GatewayMintRefused, buildWizardPropertiesBlob, + configureGatewayCredentialsForCI, + configureGatewayFromCIEnvironment, gatewayAuth, isPastRefresh, isTrustedGatewayUrl, @@ -54,6 +56,84 @@ describe('gatewayAuth', () => { vi.unstubAllGlobals(); }); + it('uses the supplied CI bearer across programs and time without minting', async () => { + configureGatewayCredentialsForCI( + ' opaque-ci-token ', + 42, + 'https://ai-gateway.us.posthog.com/', + ); + const auth = { + token: 'opaque-ci-token', + teamId: 42, + gatewayUrl: 'https://ai-gateway.us.posthog.com', + refreshAtMs: Infinity, + }; + const results = await Promise.all( + ['integration', 'audit', undefined].map((program) => + gatewayAuth(host, 'phx_project', program), + ), + ); + expect(results).toEqual([auth, auth, auth]); + const clock = vi + .spyOn(Date, 'now') + .mockReturnValue(Number.MAX_SAFE_INTEGER); + try { + expect(await gatewayAuth(host, 'phx_project', 'integration')).toEqual( + auth, + ); + expect(isPastRefresh(auth)).toBe(false); + } finally { + clock.mockRestore(); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['', 42, 'https://ai-gateway.us.posthog.com'], + ['token', 0, 'https://ai-gateway.us.posthog.com'], + ['token', 1.5, 'https://ai-gateway.us.posthog.com'], + ['token', NaN, 'https://ai-gateway.us.posthog.com'], + ['token', 42, 'https://untrusted.example'], + ['token', 42, 'https://ai-gateway.us.posthog.com/v1'], + ['token', 42, 'ftp://localhost'], + ] as const)( + 'rejects invalid CI gateway configuration', + (token, projectId, url) => { + expect(() => + configureGatewayCredentialsForCI(token, projectId, url), + ).toThrow(); + }, + ); + + it('rejects direct CI gateway auth in production builds', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.resetModules(); + try { + const prod = await import('@lib/gateway-session'); + expect(() => + prod.configureGatewayCredentialsForCI( + 'token', + 42, + 'https://ai-gateway.us.posthog.com', + ), + ).toThrow('non-production'); + } finally { + vi.unstubAllEnvs(); + vi.resetModules(); + } + }); + + it('requires an explicit gateway token file for CI', () => { + vi.stubEnv('WIZARD_CI_GATEWAY_TOKEN_FILE', ''); + try { + expect(() => configureGatewayFromCIEnvironment(42, 'us')).toThrow( + 'WIZARD_CI_GATEWAY_TOKEN_FILE is required', + ); + } finally { + vi.unstubAllEnvs(); + } + }); + it('resolves auth from a mint response and caches it', async () => { fetchMock.mockResolvedValue({ ok: true, diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index b26da023..deee97c9 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -6,18 +6,21 @@ * silent downgrade would spend uncapped, unattributed money to hide an outage. */ +import { readFileSync } from 'node:fs'; import { logToFile } from '@utils/debug'; import { analytics } from '@utils/analytics'; import { WizardError } from '@utils/wizard-abort'; import { ErrorCodes } from '@lib/errors'; import type { HostResolution } from '@lib/host-resolution'; +import { IS_PRODUCTION_BUILD, runtimeEnv } from '@env'; +import type { CloudRegion } from '@utils/types'; export interface GatewayAuth { /** Base URL for model calls (no `/v1`; transports append their route). */ gatewayUrl: string; - /** Bearer for the gateway: the minted `phe_`. */ + /** Gateway bearer, minted normally or supplied directly by CI. */ token: string; - /** The team the mint verified; rides the blob so dashboards keep a breakdown. */ + /** Team verified by the mint, or explicitly supplied for CI attribution. */ teamId?: number; /** * Instant past which a 401 on this bearer is age rather than a bad @@ -40,6 +43,51 @@ let cached: CachedAuth | null = null; * task at once, and each would otherwise take its own token and its own cap. */ let inFlight: { key: string; promise: Promise } | null = null; +let ciAuth: GatewayAuth | null = null; + +// Snapshot CI supplies a gateway bearer without minting or re-minting. +export function configureGatewayCredentialsForCI( + token: string, + projectId: number, + gatewayUrl: string, +): void { + if (IS_PRODUCTION_BUILD) + throw new Error('CI gateway auth requires a non-production build'); + if (!token.trim() || !Number.isSafeInteger(projectId) || projectId <= 0) { + throw new Error('CI gateway auth requires a token and valid project ID'); + } + if ( + !/^https?:\/\//.test(gatewayUrl) || + !isTrustedGatewayUrl(gatewayUrl, '') + ) { + throw new Error('CI gateway auth requires a trusted gateway origin'); + } + resetGatewaySession(); + ciAuth = { + token: token.trim(), + teamId: projectId, + gatewayUrl: gatewayUrl.replace(/\/+$/, ''), + refreshAtMs: Infinity, + }; +} + +export function configureGatewayFromCIEnvironment( + projectId: number, + region: CloudRegion, +): void { + if (IS_PRODUCTION_BUILD) + throw new Error('CI gateway auth requires a non-production build'); + const path = runtimeEnv('WIZARD_CI_GATEWAY_TOKEN_FILE'); + if (!path) throw new Error('WIZARD_CI_GATEWAY_TOKEN_FILE is required for CI'); + const token = readFileSync(path, 'utf8'); + delete process.env.WIZARD_CI_GATEWAY_TOKEN_FILE; + configureGatewayCredentialsForCI( + token, + projectId, + runtimeEnv('WIZARD_CI_GATEWAY_URL') || + `https://ai-gateway.${region}.posthog.com`, + ); +} /** * Adoption floor. The anthropic subprocess holds its credential until a 401 @@ -62,6 +110,7 @@ export async function gatewayAuth( accessToken: string, program: string | undefined, ): Promise { + if (ciAuth) return ciAuth; // Keyed by program: a token pins `wizard:`, so reusing one across // programs bills the wrong budget. const key = `${host.apiHost}\n${accessToken}\n${program ?? ''}`; @@ -127,6 +176,7 @@ async function resolveGatewayAuth( export function resetGatewaySession(): void { cached = null; inFlight = null; + ciAuth = null; } /** Whether a 401 on this bearer may be age (past its refresh instant) rather than a bad credential. */ diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index 66eccc7a..f554090f 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -217,6 +217,15 @@ export function runNonInteractive( }; try { + if (mode === 'ci') { + const { configureGatewayFromCIEnvironment } = await import( + '@lib/gateway-session' + ); + configureGatewayFromCIEnvironment( + Number(session.projectId), + session.region ?? 'us', + ); + } if (config.ciPreRun) { await config.ciPreRun(session); } else { From 41329aa28304416d1ecda984ebc934b052403045 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Thu, 10 Sep 2026 17:38:12 -0400 Subject: [PATCH 2/2] ci(smoke): pass the gateway token through WIZARD_CI_GATEWAY_TOKEN_FILE Co-Authored-By: Claude Fable 5.1 --- .github/workflows/smoke-test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 4c4ec297..97e09be1 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -60,6 +60,11 @@ 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; } + - name: Write gateway token + run: printf '%s' "$GATEWAY_TOKEN" > "$RUNNER_TEMP/gateway-token" + env: + GATEWAY_TOKEN: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_GATEWAY_TOKEN }} + - name: Run smoke test env: POSTHOG_PERSONAL_API_KEY: ${{ secrets.GH_APP_POSTHOG_WIZARD_CI_BOT_POSTHOG_PERSONAL_KEY }} @@ -67,6 +72,7 @@ jobs: # 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_CI_GATEWAY_TOKEN_FILE: ${{ runner.temp }}/gateway-token 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"