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 1a7a6c3b..8a1a0dd4 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, + }, ); }, ); @@ -821,3 +827,331 @@ describe('isTrustedGatewayUrl', () => { ).toBe(true); }); }); + +describe('gatewayAuth with a CI identity', () => { + const fetchMock = vi.fn(); + 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() + 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 each identity request with a new token. + 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('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 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(mintBearers()).toEqual(['Bearer identity.token.1']); + }); + + it('asks GitHub for a new identity token when it re-mints', async () => { + // 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'); + 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 () => { + // A broken identity path gets a 401, which the CI fallback admits for a personal key. + setLegacyGatewayFallback(true); + try { + route(() => refused); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintRefused); + } finally { + setLegacyGatewayFallback(false); + } + }); + + it('fails the run without minting when GitHub gives no identity token', async () => { + setLegacyGatewayFallback(true); + try { + 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('still lets a user run fall back on the same refusal', async () => { + vi.stubEnv('WIZARD_CI_IDENTITY', ''); + setLegacyGatewayFallback(true); + try { + 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('names the identity that minted, so a CI run is separable in the log', async () => { + route(() => minted()); + await gatewayAuth(host, 'phx_personal', 'integration'); + 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(loggedLines().join('\n')).toContain('identity=user'); + }); + + 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); + }); + + 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("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'); + available = false; + vi.setSystemTime(start + 151_000); + await expect( + gatewayAuth(host, 'phx_personal', 'integration'), + ).rejects.toThrow(); + }); + + 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'); + 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('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'); + 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 29a1feaf..f13edd79 100644 --- a/src/lib/agent/__tests__/agent-env-isolation.test.ts +++ b/src/lib/agent/__tests__/agent-env-isolation.test.ts @@ -36,6 +36,17 @@ describe('isBlockedAgentEnvKey', () => { expect(isBlockedAgentEnvKey('POSTHOG_TASK_ID')).toBe(true); }); + it('blocks the means to ask GitHub for an identity token', () => { + 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. @@ -135,6 +146,8 @@ describe('sanitizeAgentSubprocessEnv', () => { POSTHOG_HANDOFF_OUTPUT_PATH: '/run/task-42/handoff.md', POSTHOG_TASK_RUN_ID: 'task-42', POSTHOG_TASK_ID: '019abc', + 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..82c2b432 100644 --- a/src/lib/agent/agent-env-isolation.ts +++ b/src/lib/agent/agent-env-isolation.ts @@ -33,6 +33,9 @@ */ const PROVIDER_ENV_NAMESPACE = /^(ANTHROPIC_|CLAUDE_CODE_)/; +/** 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/; + /** * 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 @@ -152,6 +155,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/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index feff69d1..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 or failure here ends the run with its own message. + // 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 new file mode 100644 index 00000000..b60cc724 --- /dev/null +++ b/src/lib/ci-identity.ts @@ -0,0 +1,99 @@ +/** GitHub Actions identity for CI runs: a fresh single-use token from GitHub for each mint. */ + +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. */ +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 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'); + 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. +captureCiIdentityRequest(); diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 04a707b2..13feb953 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -2,9 +2,9 @@ * 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 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'; @@ -13,6 +13,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 +41,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; + /** Failed or throttled renewals while this token was cached. */ + failedRenewals: number; } let cached: CachedAuth | null = null; @@ -52,6 +61,10 @@ 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 failed or throttled renewal; it doubles each time. */ +const RENEWAL_RETRY_MS = 60_000; +/** 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. const MINT_TIMEOUT_MS = 20_000; @@ -60,6 +73,20 @@ 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; it never becomes the run's gateway credential. */ +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. */ export async function gatewayAuth( host: HostResolution, @@ -73,7 +100,10 @@ export async function gatewayAuth( return cached.auth; } if (inFlight && inFlight.key === key) return inFlight.promise; - const promise = resolveGatewayAuth(host, accessToken, 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 { return await promise; @@ -82,6 +112,30 @@ export async function gatewayAuth( } } +/** 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 + ? 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, @@ -96,17 +150,37 @@ 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, accessToken, program); + const bearer = ci ? await ciIdentityBearer() : accessToken; + minted = await mintGatewayToken(host, bearer, program, renewal); } catch (e) { if (!(e instanceof GatewayMintRefused)) throw e; - const legacy = legacyGatewayAuth(host, accessToken, e.status); + // 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( `[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); @@ -114,9 +188,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`, ); @@ -127,7 +199,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=${ + ci ? 'ci' : 'user' + }`, ); const auth: GatewayAuth = { gatewayUrl: minted.gatewayUrl, @@ -135,7 +209,7 @@ async function resolveGatewayAuth( teamId: minted.teamId, refreshAtMs: staleAtMs, }; - cached = { key, auth, staleAtMs }; + cached = { key, auth, staleAtMs, expiresAtMs, failedRenewals: 0 }; return auth; } @@ -197,9 +271,8 @@ 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, 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; @@ -226,7 +299,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. @@ -310,14 +383,15 @@ function mintRefusalMessage(status: number, detail?: string): string { async function mintGatewayToken( host: HostResolution, - accessToken: string, + bearer: string, program: string, + renewal: boolean, ): 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 @@ -332,14 +406,14 @@ 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 event has no run id, so this joins a refusal to the session. analytics.wizardCapture('gateway mint refused', { status: resp.status, outcome: refusal.outcome, program, + renewal, }); throw new GatewayMintRefused( resp.status, @@ -347,9 +421,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})`, ); @@ -363,21 +435,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 { @@ -391,9 +461,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)})`, );