diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc246a2..867d8a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,9 +58,12 @@ We read undocumented endpoints as guests: exponential backoff on failures — your adapter doesn't need to implement backoff, just report honest statuses. - Use `ctx.fetch(url, { credentials: 'include', headers: { Accept: - 'application/json' } })`. Do not spoof another client's identity, add - fake client headers, or construct `Authorization` headers. Cookie-riding - Tier A adapters must never read credential values at all. + 'application/json' } })`. Do not spoof another client's identity or add + fake client headers. Never read cookie values. If the provider's own web + app authenticates with a session-minted bearer token (see the Codex + adapter), obtain it from that provider's own session endpoint at fetch + time, keep it in function scope, and never persist it — not in + `ctx.cache`, not in storage, not in logs. - **Anthropic-specific:** PRs that read, store, or transmit Claude Code / claude.ai OAuth tokens will be declined — Anthropic's terms restrict those tokens to their own products (see README). diff --git a/README.md b/README.md index 734562d..9d5683a 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,14 @@ itself. Privacy and safety commitments, in order of importance: -- **No credentials are ever read, stored, or transmitted.** The extension - never requests the `cookies` permission, never sees cookie values, and - never constructs an `Authorization` header (there's a test asserting - this). It cannot leak what it cannot see. +- **No credentials are ever read or stored.** The extension never requests + the `cookies` permission and never sees cookie values. Where a provider's + own web app authenticates with a short-lived session token (Codex), the + adapter asks that provider's own session endpoint for the token at + refresh time — exactly what the page itself does — uses it in-memory for + the single request, and never persists it (there's a test asserting + this). For Claude, no `Authorization` header is ever constructed at all + (also tested). - **Zero telemetry.** No analytics, no error reporting service, no remote config, no runtime dependencies at all. What you see in this repo is the entire behavior. diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts index ac3af8a..30927cc 100644 --- a/src/adapters/codex.ts +++ b/src/adapters/codex.ts @@ -1,77 +1,124 @@ /** * Codex / ChatGPT adapter (Tier A). * - * Rides the user's existing chatgpt.com browser session via - * `credentials: 'include'` — no tokens are read, stored, or constructed. + * Auth: the ChatGPT web app authenticates its backend calls with a + * short-lived bearer token minted from the browser session. This adapter + * does the same thing the page itself does: it asks the provider's own + * session endpoint (`/api/auth/session`, cookie-authed) for that token, + * uses it in-memory for the single usage request, and never stores it — + * nothing is written to storage or logs. * - * The endpoint is undocumented and has been observed with more than one - * field-name convention (`primary_window`/`secondary_window` vs. - * `five_hour_limit`/`weekly_limit`). Field names are matched literally per - * variant — never inferred from display labels — because mislabeling which - * window is which produces a confidently wrong display, which is worse than - * no display. An unknown shape is an error state, never a zeroed lane. + * The endpoint is undocumented and has been observed with several shapes. + * Field names are matched literally per named variant — never inferred from + * display labels — because mislabeling which window is which produces a + * confidently wrong display, worse than no display. An unknown shape is an + * error state, never a zeroed lane. The primary variant (`wham_rate_limit`, + * windows nested under `rate_limit`) is verified against a live capture + * (2026-08); the flat shapes remain as fallbacks. */ import type { FetchContext, ProviderAdapter, ProviderSnapshot, QuotaLane } from '../types'; import { clampPct } from '../lib/headroom'; import { arr, matchVariant, nullable, num, obj, optional, str, type Schema } from '../lib/validate'; -export const CODEX_ADAPTER_VERSION = 1; +export const CODEX_ADAPTER_VERSION = 2; +const SESSION_URL = 'https://chatgpt.com/api/auth/session'; const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; interface UsageWindow { used_percent: number; + limit_window_seconds?: number | null; + reset_at?: number | null; + reset_after_seconds?: number | null; resets_at?: number | null; resets_in_seconds?: number | null; } const windowSchema: Schema = obj({ used_percent: num, + limit_window_seconds: optional(nullable(num)), + reset_at: optional(nullable(num)), + reset_after_seconds: optional(nullable(num)), resets_at: optional(nullable(num)), resets_in_seconds: optional(nullable(num)), }); -interface AdditionalLimit extends UsageWindow { - name?: string; +interface RateLimitBlock { + primary_window?: UsageWindow | null; + secondary_window?: UsageWindow | null; } -const additionalLimitSchema: Schema = obj({ - name: optional(str), - used_percent: num, - resets_at: optional(nullable(num)), - resets_in_seconds: optional(nullable(num)), +const rateLimitBlockSchema: Schema = obj({ + primary_window: optional(nullable(windowSchema)), + secondary_window: optional(nullable(windowSchema)), }); +interface AdditionalRateLimit { + limit_name?: string | null; + rate_limit?: RateLimitBlock | null; +} + +/** Live shape: windows nested under rate_limit. */ +interface RateLimitShape { + rate_limit: RateLimitBlock; + additional_rate_limits?: AdditionalRateLimit[] | null; +} + +/** Legacy/fallback shapes: windows at the top level. */ interface WindowsShape { primary_window: UsageWindow; - secondary_window?: UsageWindow; - additional_rate_limits?: AdditionalLimit[]; + secondary_window?: UsageWindow | null; } interface NamedShape { five_hour_limit: UsageWindow; - weekly_limit?: UsageWindow; - additional_rate_limits?: AdditionalLimit[]; + weekly_limit?: UsageWindow | null; } +type UsageShape = RateLimitShape | WindowsShape | NamedShape; + +const additionalSchema: Schema = obj({ + limit_name: optional(nullable(str)), + rate_limit: optional(nullable(rateLimitBlockSchema)), +}); + const variants = { + wham_rate_limit: obj({ + rate_limit: rateLimitBlockSchema, + additional_rate_limits: optional(nullable(arr(additionalSchema))), + }) as Schema, wham_windows: obj({ primary_window: windowSchema, - secondary_window: optional(windowSchema), - additional_rate_limits: optional(arr(additionalLimitSchema)), - }) as Schema, + secondary_window: optional(nullable(windowSchema)), + }) as Schema, wham_named: obj({ five_hour_limit: windowSchema, - weekly_limit: optional(windowSchema), - additional_rate_limits: optional(arr(additionalLimitSchema)), - }) as Schema, + weekly_limit: optional(nullable(windowSchema)), + }) as Schema, }; +const sessionSchema = obj<{ accessToken?: string }>({ + accessToken: optional(str), +}); + +function windowLabel(w: UsageWindow, slot: 'session' | 'weekly'): string { + const secs = w.limit_window_seconds; + if (typeof secs === 'number' && secs > 0) { + if (secs === 18_000) return 'Session (5h)'; + if (secs === 604_800) return 'Weekly'; + const hours = Math.round(secs / 3600); + return hours >= 48 ? `${Math.round(hours / 24)}-day window` : `${hours}h window`; + } + return slot === 'session' ? 'Session' : 'Weekly'; +} + function windowToLane(id: string, label: string, w: UsageWindow, now: Date): QuotaLane { let resetsAt: string | null = null; - if (typeof w.resets_at === 'number') { - resetsAt = new Date(w.resets_at * 1000).toISOString(); - } else if (typeof w.resets_in_seconds === 'number') { - resetsAt = new Date(now.getTime() + w.resets_in_seconds * 1000).toISOString(); + const epoch = w.reset_at ?? w.resets_at; + const relative = w.reset_after_seconds ?? w.resets_in_seconds; + if (typeof epoch === 'number') { + resetsAt = new Date(epoch * 1000).toISOString(); + } else if (typeof relative === 'number') { + resetsAt = new Date(now.getTime() + relative * 1000).toISOString(); } return { id, @@ -84,23 +131,43 @@ function windowToLane(id: string, label: string, w: UsageWindow, now: Date): Quo }; } -function toLanes(value: WindowsShape | NamedShape, now: Date): QuotaLane[] { +function blockToLanes(block: RateLimitBlock, now: Date): QuotaLane[] { const lanes: QuotaLane[] = []; + if (block.primary_window) { + lanes.push( + windowToLane('session', windowLabel(block.primary_window, 'session'), block.primary_window, now), + ); + } + if (block.secondary_window) { + lanes.push( + windowToLane('weekly', windowLabel(block.secondary_window, 'weekly'), block.secondary_window, now), + ); + } + return lanes; +} + +function toLanes(value: UsageShape, now: Date): QuotaLane[] { + if ('rate_limit' in value) { + const lanes = blockToLanes(value.rate_limit, now); + (value.additional_rate_limits ?? []).forEach((extra, i) => { + const w = extra.rate_limit?.primary_window; + if (!w) return; + const name = extra.limit_name ?? `limit ${i + 1}`; + lanes.push(windowToLane(`extra:${extra.limit_name ?? i}`, name, w, now)); + }); + return lanes; + } if ('primary_window' in value) { - lanes.push(windowToLane('session', 'Session', value.primary_window, now)); + const lanes = [windowToLane('session', 'Session', value.primary_window, now)]; if (value.secondary_window) { lanes.push(windowToLane('weekly', 'Weekly', value.secondary_window, now)); } - } else { - lanes.push(windowToLane('session', 'Session (5h)', value.five_hour_limit, now)); - if (value.weekly_limit) { - lanes.push(windowToLane('weekly', 'Weekly', value.weekly_limit, now)); - } + return lanes; + } + const lanes = [windowToLane('session', 'Session (5h)', value.five_hour_limit, now)]; + if (value.weekly_limit) { + lanes.push(windowToLane('weekly', 'Weekly', value.weekly_limit, now)); } - (value.additional_rate_limits ?? []).forEach((extra, i) => { - const name = extra.name ?? `limit ${i + 1}`; - lanes.push(windowToLane(`extra:${extra.name ?? i}`, name, extra, now)); - }); return lanes; } @@ -125,11 +192,51 @@ export const codexAdapter: ProviderAdapter = { message: string, ): ProviderSnapshot => ({ ...base, status, lanes: [], error: { code, message } }); + // Step 1: mint the short-lived bearer the same way the ChatGPT page does. + // The token lives only in this function scope; it is never persisted. + let sessionResponse: Response; + try { + sessionResponse = await ctx.fetch(SESSION_URL, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + } catch (err) { + return failed('error', 'network', err instanceof Error ? err.message : String(err)); + } + if (sessionResponse.status === 401 || sessionResponse.status === 403) { + return failed('unauthenticated', 'not_logged_in', 'Log in at chatgpt.com'); + } + if (sessionResponse.status === 429) { + return failed('rate_limited', 'rate_limited', 'chatgpt.com rate-limited us; backing off'); + } + if (!sessionResponse.ok) { + return failed( + 'error', + `http_${sessionResponse.status}`, + `chatgpt.com session endpoint returned ${sessionResponse.status}`, + ); + } + let sessionBody: unknown; + try { + sessionBody = await sessionResponse.json(); + } catch { + return failed('unauthenticated', 'not_logged_in', 'Log in at chatgpt.com'); + } + const session = sessionSchema(sessionBody, 'session'); + // A logged-out session returns 200 with an empty object. + if (!session.ok || !session.value.accessToken) { + return failed('unauthenticated', 'not_logged_in', 'Log in at chatgpt.com'); + } + + // Step 2: the usage call, authenticated exactly like the page's own. let response: Response; try { response = await ctx.fetch(USAGE_URL, { credentials: 'include', - headers: { Accept: 'application/json' }, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${session.value.accessToken}`, + }, }); } catch (err) { return failed('error', 'network', err instanceof Error ? err.message : String(err)); diff --git a/tests/codex.test.ts b/tests/codex.test.ts index 9e3dd6f..615c37c 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -6,9 +6,60 @@ import { htmlResponse, jsonResponse, makeCtx, NOW } from './helpers'; const fixture = (name: string): unknown => JSON.parse(readFileSync(new URL(`./fixtures/codex/${name}`, import.meta.url), 'utf8')); +const SESSION_URL = 'https://chatgpt.com/api/auth/session'; +const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; + +/** Responder with a live session; usage body selected per test. */ +const withSession = + (usageBody: () => Response) => + (url: string): Response => { + if (url === SESSION_URL) return jsonResponse(fixture('auth-session.json')); + if (url === USAGE_URL) return usageBody(); + return jsonResponse({}, 404); + }; + +const headersOf = (init: RequestInit | undefined): Record => + Object.fromEntries( + Object.entries((init?.headers as Record) ?? {}).map(([k, v]) => [ + k.toLowerCase(), + v, + ]), + ); + describe('codex adapter', () => { - it('parses the primary_window/secondary_window variant', async () => { - const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.primary-window.json'))); + it('parses the live rate_limit shape with additional limits', async () => { + const ctx = makeCtx(withSession(() => jsonResponse(fixture('wham-usage.rate-limit.json')))); + const snap = await codexAdapter.fetch(ctx); + + expect(snap.status).toBe('ok'); + expect(snap.schemaVariant).toBe('wham_rate_limit'); + expect(snap.lanes.map((l) => [l.id, l.label, l.headroomPct])).toEqual([ + ['session', 'Session (5h)', 76], + ['weekly', 'Weekly', 96], + ['extra:gpt-reserve', 'gpt-reserve', 100], + ]); + // reset_at (epoch seconds) wins over reset_after_seconds + expect(snap.lanes[0]?.resetsAt).toBe(new Date(1787962525 * 1000).toISOString()); + }); + + it('mints the bearer from the session endpoint and never persists it', async () => { + const ctx = makeCtx(withSession(() => jsonResponse(fixture('wham-usage.rate-limit.json')))); + await codexAdapter.fetch(ctx); + + expect(ctx.requests.map((r) => r.url)).toEqual([SESSION_URL, USAGE_URL]); + // session call: cookie-riding only, no Authorization + const sessionHeaders = headersOf(ctx.requests[0]?.init); + expect(ctx.requests[0]?.init?.credentials).toBe('include'); + expect(sessionHeaders).not.toHaveProperty('authorization'); + // usage call: the page's own auth style — bearer from the session response + const usageHeaders = headersOf(ctx.requests[1]?.init); + expect(usageHeaders['authorization']).toBe('Bearer test-access-token-XXX'); + // the token is never written to the adapter's persistent cache + expect(ctx.cached()).toBeUndefined(); + }); + + it('parses the legacy top-level primary_window variant', async () => { + const ctx = makeCtx(withSession(() => jsonResponse(fixture('wham-usage.primary-window.json')))); const snap = await codexAdapter.fetch(ctx); expect(snap.status).toBe('ok'); @@ -17,14 +68,11 @@ describe('codex adapter', () => { ['session', 81], ['weekly', 96], ]); - // resets_in_seconds is relative to the injected clock expect(snap.lanes[0]?.resetsAt).toBe(new Date(NOW.getTime() + 16_980_000).toISOString()); - // resets_at is epoch seconds - expect(snap.lanes[1]?.resetsAt).toBe(new Date(1756897800 * 1000).toISOString()); }); - it('parses the five_hour_limit/weekly_limit variant (field-name drift)', async () => { - const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.five-hour-limit.json'))); + it('parses the legacy five_hour_limit variant (field-name drift)', async () => { + const ctx = makeCtx(withSession(() => jsonResponse(fixture('wham-usage.five-hour-limit.json')))); const snap = await codexAdapter.fetch(ctx); expect(snap.status).toBe('ok'); @@ -35,18 +83,19 @@ describe('codex adapter', () => { ]); }); - it('maps additional_rate_limits to extra lanes', async () => { - const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.additional-limits.json'))); + it('treats a logged-out session (200 with no accessToken) as unauthenticated', async () => { + const ctx = makeCtx((url) => + url === SESSION_URL ? jsonResponse({}) : jsonResponse(fixture('wham-usage.rate-limit.json')), + ); const snap = await codexAdapter.fetch(ctx); - expect(snap.status).toBe('ok'); - expect(snap.lanes.map((l) => l.id)).toEqual(['session', 'extra:gpt-5-pro', 'extra:1']); - expect(snap.lanes[1]?.label).toBe('gpt-5-pro'); - expect(snap.lanes[1]?.headroomPct).toBe(25); + expect(snap.status).toBe('unauthenticated'); + // never calls the usage endpoint without a token + expect(ctx.requests.map((r) => r.url)).toEqual([SESSION_URL]); }); - it('reports an unknown shape as schema_mismatch, never a zeroed lane', async () => { - const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.unknown-shape.json'))); + it('reports an unknown usage shape as schema_mismatch, never a zeroed lane', async () => { + const ctx = makeCtx(withSession(() => jsonResponse(fixture('wham-usage.unknown-shape.json')))); const snap = await codexAdapter.fetch(ctx); expect(snap.status).toBe('error'); @@ -54,7 +103,7 @@ describe('codex adapter', () => { expect(snap.lanes).toEqual([]); }); - it('maps 401/403 to unauthenticated and 429 to rate_limited', async () => { + it('maps session-endpoint statuses: 401/403 unauthenticated, 429 rate_limited', async () => { for (const [status, expected] of [ [401, 'unauthenticated'], [403, 'unauthenticated'], @@ -67,8 +116,23 @@ describe('codex adapter', () => { } }); - it('treats a non-JSON body as an error state', async () => { - const ctx = makeCtx(() => htmlResponse(200)); + it('maps usage-endpoint statuses after a good session', async () => { + for (const [status, expected] of [ + [401, 'unauthenticated'], + [429, 'rate_limited'], + [500, 'error'], + ] as const) { + const ctx = makeCtx(withSession(() => jsonResponse({}, status))); + const snap = await codexAdapter.fetch(ctx); + expect(snap.status).toBe(expected); + } + }); + + it('treats a non-JSON session body as unauthenticated and usage body as error', async () => { + const loggedOut = makeCtx(() => htmlResponse(200)); + expect((await codexAdapter.fetch(loggedOut)).status).toBe('unauthenticated'); + + const ctx = makeCtx(withSession(() => htmlResponse(200))); const snap = await codexAdapter.fetch(ctx); expect(snap.status).toBe('error'); expect(snap.error?.code).toBe('not_json'); @@ -82,15 +146,4 @@ describe('codex adapter', () => { expect(snap.status).toBe('error'); expect(snap.error?.code).toBe('network'); }); - - it('rides the browser session: credentials include, no Authorization header', async () => { - const ctx = makeCtx(() => jsonResponse(fixture('wham-usage.primary-window.json'))); - await codexAdapter.fetch(ctx); - - expect(ctx.requests).toHaveLength(1); - const init = ctx.requests[0]?.init; - expect(init?.credentials).toBe('include'); - const headers = Object.keys((init?.headers as Record) ?? {}); - expect(headers.map((h) => h.toLowerCase())).not.toContain('authorization'); - }); }); diff --git a/tests/fixtures/codex/auth-session.json b/tests/fixtures/codex/auth-session.json new file mode 100644 index 0000000..12afec1 --- /dev/null +++ b/tests/fixtures/codex/auth-session.json @@ -0,0 +1,9 @@ +{ + "user": { + "id": "user-XXXXXXXXXXXXXXXXXXXX", + "name": "Example User", + "email": "user@example.com" + }, + "expires": "2026-11-26T20:23:13.000Z", + "accessToken": "test-access-token-XXX" +} diff --git a/tests/fixtures/codex/wham-usage.rate-limit.json b/tests/fixtures/codex/wham-usage.rate-limit.json new file mode 100644 index 0000000..19160fa --- /dev/null +++ b/tests/fixtures/codex/wham-usage.rate-limit.json @@ -0,0 +1,46 @@ +{ + "user_id": "user-XXXXXXXXXXXXXXXXXXXX", + "account_id": "", + "email": "user@example.com", + "plan_type": "plus", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 24, + "limit_window_seconds": 18000, + "reset_after_seconds": 13933, + "reset_at": 1787962525 + }, + "secondary_window": { + "used_percent": 4, + "limit_window_seconds": 604800, + "reset_after_seconds": 580683, + "reset_at": 1788529276 + } + }, + "code_review_rate_limit": null, + "additional_rate_limits": [ + { + "limit_name": "gpt-reserve", + "metered_feature": "base_model_inference", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 0, + "limit_window_seconds": 604800, + "reset_after_seconds": 604800, + "reset_at": 1788553393 + }, + "secondary_window": null + } + } + ], + "credits": { + "has_credits": false, + "unlimited": false + }, + "rate_limit_reached_type": null, + "promo": null +}