diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 153db3514..2c0b089ca 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -296,7 +296,15 @@ jobs: ANTHROPIC_API_KEY: ${{ matrix.mock_sdk == true && 'sk-devproxy-test-key' || ((startsWith(matrix.module, 'features') || startsWith(matrix.module, 'room')) && secrets.ANTHROPIC_API_KEY || '') }} ANTHROPIC_AUTH_TOKEN: "" CLAUDE_CODE_OAUTH_TOKEN: ${{ matrix.mock_sdk != true && (startsWith(matrix.module, 'features') || startsWith(matrix.module, 'room')) && secrets.CLAUDE_CODE_OAUTH_TOKEN || '' }} - OPENAI_API_KEY: "" + # CODEX_OAUTH_TOKEN — ChatGPT OAuth access token for the Codex bridge provider. + # How to obtain: run `codex login` locally, then read the access token: + # cat ~/.codex/auth.json | jq -r '.tokens.access_token' + # Store the value as a GitHub Actions secret named CODEX_OAUTH_TOKEN. + # Note: OAuth access tokens expire (typically after a few hours). Refresh via: + # cat ~/.codex/auth.json | jq -r '.tokens.access_token' (codex CLI refreshes on use) + # Alternative: set CODEX_API_KEY instead (an OpenAI API key — paid but doesn't expire). + # If the secret is absent the test FAILS (hard-fail, not skip — by design). + CODEX_OAUTH_TOKEN: ${{ matrix.module == 'providers-anthropic-to-codex-bridge' && secrets.CODEX_OAUTH_TOKEN || '' }} # Requires a PAT with the `copilot_requests` scope stored as COPILOT_GITHUB_TOKEN. # secrets.GITHUB_TOKEN is a repo-scoped installation token and cannot authenticate Copilot API calls. # If the secret is absent the env var is empty and the test FAILS (hard-fail, not skip — by design). diff --git a/packages/daemon/.env.example b/packages/daemon/.env.example index 791b8e0dc..6767b3d66 100644 --- a/packages/daemon/.env.example +++ b/packages/daemon/.env.example @@ -37,6 +37,32 @@ HOST=0.0.0.0 # Option 3: Third-party provider (configure in ~/.claude/settings.json) # See: https://docs.anthropic.com/en/docs/claude-code/settings +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Codex bridge provider (anthropic-to-codex-bridge) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# The Codex bridge routes requests through OpenAI's Codex app-server. +# Credential discovery order (highest priority first): +# 1. CODEX_OAUTH_TOKEN — ChatGPT OAuth access token (free with Plus/Pro subscription) +# 2. OPENAI_API_KEY — OpenAI API key (paid per-token) +# 3. CODEX_API_KEY — Alias for OPENAI_API_KEY +# 4. ~/.neokai/auth.json ["openai"] — stored after UI login +# 5. ~/.codex/auth.json — one-time import from the Codex CLI +# +# How to obtain CODEX_OAUTH_TOKEN: +# 1. Install the Codex CLI: npm install -g @openai/codex +# 2. Run: codex login (opens a browser to authenticate with your ChatGPT account) +# 3. Extract the access token: cat ~/.codex/auth.json | jq -r '.tokens.access_token' +# 4. Set CODEX_OAUTH_TOKEN to that value. +# +# Note: OAuth access tokens are short-lived JWTs (typically a few hours). +# For CI, store the token as a secret and refresh it periodically by re-running +# `codex login` and updating the secret. Alternatively, use CODEX_API_KEY (an +# OpenAI API key) which does not expire but incurs per-token cost. +# +# CODEX_OAUTH_TOKEN= +# CODEX_API_KEY=sk-... + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # GitHub Copilot provider (anthropic-copilot) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/packages/daemon/src/lib/providers/anthropic-to-codex-bridge-provider.ts b/packages/daemon/src/lib/providers/anthropic-to-codex-bridge-provider.ts index 386d9963b..18972e201 100644 --- a/packages/daemon/src/lib/providers/anthropic-to-codex-bridge-provider.ts +++ b/packages/daemon/src/lib/providers/anthropic-to-codex-bridge-provider.ts @@ -6,9 +6,10 @@ * backed by `codex app-server`. * * Authentication discovery for API calls (priority order): - * 1. OPENAI_API_KEY / CODEX_API_KEY environment variable (daemon/test use only) - * 2. ~/.neokai/auth.json — NeoKai's own auth store (key "openai") - * 3. ~/.codex/auth.json — imported once into ~/.neokai/auth.json (for users who ran `codex login`) + * 1. CODEX_OAUTH_TOKEN environment variable — OAuth access token (preferred for CI) + * 2. OPENAI_API_KEY / CODEX_API_KEY environment variable — API key (daemon/test use only) + * 3. ~/.neokai/auth.json — NeoKai's own auth store (key "openai") + * 4. ~/.codex/auth.json — imported once into ~/.neokai/auth.json (for users who ran `codex login`) * * UI authentication requires NeoKai-managed OAuth credentials in ~/.neokai/auth.json. * Env var credentials are used internally for API calls but not shown in the UI. @@ -119,6 +120,61 @@ export interface OpenAIOAuthToken { id_token?: string; } +// --------------------------------------------------------------------------- +// Module-level helpers for env-var OAuth token auth +// --------------------------------------------------------------------------- + +/** Parse a JWT payload section, returning the decoded object or undefined on failure. */ +function parseJwtPayload(token: string): Record | undefined { + try { + const parts = token.split('.'); + if (parts.length !== 3) return undefined; + return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as Record< + string, + unknown + >; + } catch { + return undefined; + } +} + +/** + * Build an AppServerAuth from a raw CODEX_OAUTH_TOKEN env var value. + * + * Produces `chatgpt`-type auth only when the OpenAI-specific `chatgpt_account_id` + * claim is present in the token payload. Does NOT fall back to the generic `sub` + * claim to avoid mis-classifying non-ChatGPT JWTs (e.g. GitHub tokens) as chatgpt + * auth and sending them to the Codex app-server as OpenAI OAuth tokens. + * + * Emits a logger warning when the token is not a recognisable ChatGPT JWT and falls + * back to `api_key` auth so that CI operators can spot the mis-configuration. + * + * Exported for unit testing. + */ +export function buildAuthFromEnvOAuthToken( + token: string, + warnFn: (msg: string) => void = () => {} +): AppServerAuth { + const payload = parseJwtPayload(token); + const openaiAuth = payload?.['https://api.openai.com/auth'] as Record | undefined; + const chatgptAccountId = openaiAuth?.chatgpt_account_id; + + if (chatgptAccountId) { + return { + type: 'chatgpt', + accessToken: token, + chatgptAccountId, + chatgptPlanType: openaiAuth?.chatgpt_plan_type, + }; + } + + warnFn( + 'CODEX_OAUTH_TOKEN does not contain a ChatGPT account ID; treating as API key. ' + + 'Verify the token is an OpenAI OAuth access token if ChatGPT auth is expected.' + ); + return { type: 'api_key', apiKey: token }; +} + /** * Exchange a Codex/OpenAI OAuth refresh token for a new access token. * Returns the full token response, or null if the exchange fails for any reason. @@ -261,11 +317,28 @@ export class AnthropicToCodexBridgeProvider implements Provider { /** * Return provider credentials for codex app-server, following discovery order: - * 1. OPENAI_API_KEY / CODEX_API_KEY env var - * 2. ~/.neokai/auth.json["openai"] - * 3. One-time migration from ~/.codex/auth.json into ~/.neokai/auth.json + * 1. CODEX_OAUTH_TOKEN env var (OAuth access token — preferred for CI) + * 2. OPENAI_API_KEY / CODEX_API_KEY env var (API key) + * 3. ~/.neokai/auth.json["openai"] + * 4. One-time migration from ~/.codex/auth.json into ~/.neokai/auth.json */ private async getBridgeAuth(): Promise { + if (this.env.CODEX_OAUTH_TOKEN) { + // Return cached result so repeated calls (isAvailable, getApiKey, getModels, etc.) + // don't re-parse the JWT or re-emit the api_key fallback warning. + if (this.cachedBridgeAuth !== undefined) { + return this.cachedBridgeAuth ?? undefined; + } + const auth = buildAuthFromEnvOAuthToken(this.env.CODEX_OAUTH_TOKEN, (msg) => + logger.warn(msg) + ); + this.cachedBridgeAuth = auth; + this.cachedApiKey = + auth.type === 'api_key' + ? auth.apiKey + : (auth as Extract).accessToken; + return auth; + } if (this.env.OPENAI_API_KEY) { return { type: 'api_key', apiKey: this.env.OPENAI_API_KEY }; } @@ -454,11 +527,21 @@ export class AnthropicToCodexBridgeProvider implements Provider { const codexBinaryPath = this.codexFinder() ?? 'codex'; // buildSdkConfig() is synchronous per the Provider interface. The async // discovery chain populates cachedBridgeAuth via isAvailable()/getAuthStatus(). - const envAuth = this.env.OPENAI_API_KEY - ? ({ type: 'api_key', apiKey: this.env.OPENAI_API_KEY } as const) - : this.env.CODEX_API_KEY - ? ({ type: 'api_key', apiKey: this.env.CODEX_API_KEY } as const) - : undefined; + let envAuth: AppServerAuth | undefined; + if (this.env.CODEX_OAUTH_TOKEN) { + // Prefer cachedBridgeAuth populated by getBridgeAuth() to avoid re-parsing the JWT + // and re-emitting the warning on every new workspace. + // Use !== undefined (not ??) so that null ("resolved, unavailable") is treated + // as a definitive cache hit and does not fall through to re-parse the token. + envAuth = + this.cachedBridgeAuth !== undefined + ? (this.cachedBridgeAuth ?? undefined) + : buildAuthFromEnvOAuthToken(this.env.CODEX_OAUTH_TOKEN, (msg) => logger.warn(msg)); + } else if (this.env.OPENAI_API_KEY) { + envAuth = { type: 'api_key', apiKey: this.env.OPENAI_API_KEY }; + } else if (this.env.CODEX_API_KEY) { + envAuth = { type: 'api_key', apiKey: this.env.CODEX_API_KEY }; + } const fileAuth = this.cachedCredentials ? this.toBridgeAuth(this.cachedCredentials) : undefined; diff --git a/packages/daemon/tests/online/providers/anthropic-to-codex-bridge-provider.test.ts b/packages/daemon/tests/online/providers/anthropic-to-codex-bridge-provider.test.ts index 69f3e58fb..c9776bcbb 100644 --- a/packages/daemon/tests/online/providers/anthropic-to-codex-bridge-provider.test.ts +++ b/packages/daemon/tests/online/providers/anthropic-to-codex-bridge-provider.test.ts @@ -5,7 +5,7 @@ * AnthropicToCodexBridgeProvider.buildSdkConfig → HTTP bridge server → codex app-server → Codex API * * REQUIREMENTS: - * - OPENAI_API_KEY or CODEX_API_KEY must be set + * - CODEX_OAUTH_TOKEN (OAuth token, preferred), OPENAI_API_KEY, or CODEX_API_KEY must be set * - The `codex` binary must be installed and on PATH * * NOTE: Dev Proxy (NEOKAI_USE_DEV_PROXY=1) does NOT apply to these tests. @@ -14,7 +14,7 @@ * Codex API. * * Run with: - * OPENAI_API_KEY=sk-xxx bun test \ + * CODEX_OAUTH_TOKEN= bun test \ * packages/daemon/tests/online/providers/anthropic-to-codex-bridge-provider.test.ts */ @@ -213,12 +213,12 @@ describe('Codex Bridge (Online)', () => { // Hard-fail if credentials are absent or the codex binary is missing — // per CLAUDE.md policy: tests must FAIL, not silently skip. - // isAvailable() checks all runtime auth sources (env vars OPENAI_API_KEY/CODEX_API_KEY, + // isAvailable() checks all runtime auth sources (env vars CODEX_OAUTH_TOKEN/OPENAI_API_KEY/CODEX_API_KEY, // auth.json OAuth) rather than isAuthenticated which is UI-only (NeoKai OAuth only). if (!(await provider.isAvailable())) { throw new Error( 'anthropic-codex provider is not available. ' + - 'Set OPENAI_API_KEY or CODEX_API_KEY, or run `codex login`.' + 'Set CODEX_OAUTH_TOKEN (OAuth token), OPENAI_API_KEY, or CODEX_API_KEY, or run `codex login`.' ); } diff --git a/packages/daemon/tests/unit/providers/anthropic-to-codex-bridge-provider.test.ts b/packages/daemon/tests/unit/providers/anthropic-to-codex-bridge-provider.test.ts index fa178daf7..365d4db76 100644 --- a/packages/daemon/tests/unit/providers/anthropic-to-codex-bridge-provider.test.ts +++ b/packages/daemon/tests/unit/providers/anthropic-to-codex-bridge-provider.test.ts @@ -12,12 +12,26 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; -import { AnthropicToCodexBridgeProvider } from '../../../src/lib/providers/anthropic-to-codex-bridge-provider'; +import { Logger } from '../../../src/lib/logger'; +import { + AnthropicToCodexBridgeProvider, + buildAuthFromEnvOAuthToken, +} from '../../../src/lib/providers/anthropic-to-codex-bridge-provider'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- +/** + * Build a fake JWT with the given payload (base64url-encoded, no real signature). + * Used to test JWT-based credential extraction without hitting a real auth server. + */ +function makeFakeJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${header}.${body}.fakesignature`; +} + /** Create a provider instance pointing at isolated temp auth dirs. */ function makeProvider( env: Record = {}, @@ -83,6 +97,15 @@ describe('AnthropicToCodexBridgeProvider', () => { expect(result.error).toBeTruthy(); }); + it('returns isAuthenticated=false when only CODEX_OAUTH_TOKEN env var is set (env vars are daemon/test only)', async () => { + const fakeToken = makeFakeJwt({ + 'https://api.openai.com/auth': { chatgpt_account_id: 'user_test123' }, + }); + provider = makeProvider({ CODEX_OAUTH_TOKEN: fakeToken }, emptyDir, emptyDir, fakeCodexFound); + const result = await provider.getAuthStatus(); + expect(result.isAuthenticated).toBe(false); + }); + it('returns isAuthenticated=false when only OPENAI_API_KEY env var is set (env vars are daemon/test only)', async () => { provider = makeProvider({ OPENAI_API_KEY: 'sk-env-key' }, emptyDir, emptyDir, fakeCodexFound); const result = await provider.getAuthStatus(); @@ -179,6 +202,38 @@ describe('AnthropicToCodexBridgeProvider', () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); + it('Priority 0: CODEX_OAUTH_TOKEN env var takes highest priority (over OPENAI_API_KEY and file-based auth)', async () => { + const fakeToken = makeFakeJwt({ + 'https://api.openai.com/auth': { chatgpt_account_id: 'user_test123' }, + }); + const neokaiDir = path.join(tmpDir, 'neokai-p0'); + const codexDir = path.join(tmpDir, 'codex-p0'); + await writeNeokaiAuth(neokaiDir, { type: 'oauth', access: 'neokai-token' }); + + provider = makeProvider( + { CODEX_OAUTH_TOKEN: fakeToken, OPENAI_API_KEY: 'env-api-key' }, + neokaiDir, + codexDir + ); + // getApiKey() returns the access token from CODEX_OAUTH_TOKEN + expect(await provider.getApiKey()).toBe(fakeToken); + }); + + it('CODEX_OAUTH_TOKEN fallback: non-JWT token treated as API key', async () => { + // If the token is not a valid JWT (no chatgptAccountId), fall back to api_key auth + const notAJwt = 'sk-plain-bearer-token'; + provider = makeProvider({ CODEX_OAUTH_TOKEN: notAJwt }); + expect(await provider.getApiKey()).toBe(notAJwt); + }); + + it('CODEX_OAUTH_TOKEN with sub-only JWT (non-OpenAI) is treated as API key, not chatgpt auth', async () => { + // A generic JWT with only a `sub` claim (e.g. GitHub token) must NOT be classified + // as chatgpt auth — it lacks the OpenAI-specific chatgpt_account_id claim. + const subOnlyToken = makeFakeJwt({ sub: 'some-user-id' }); + provider = makeProvider({ CODEX_OAUTH_TOKEN: subOnlyToken }); + expect(await provider.getApiKey()).toBe(subOnlyToken); + }); + it('Priority 1: returns OPENAI_API_KEY env var immediately', async () => { const neokaiDir = path.join(tmpDir, 'neokai'); const codexDir = path.join(tmpDir, 'codex'); @@ -293,6 +348,54 @@ describe('AnthropicToCodexBridgeProvider', () => { expect(cfg.envVars.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); }); + it('buildSdkConfig() uses CODEX_OAUTH_TOKEN env var as OAuth auth', () => { + const fakeToken = makeFakeJwt({ + 'https://api.openai.com/auth': { chatgpt_account_id: 'user_oauth_ci' }, + }); + // Verify the auth produced for this token is chatgpt-type (not api_key). + // buildAuthFromEnvOAuthToken is the shared helper used by both getBridgeAuth() + // and buildSdkConfig(), so asserting its output confirms the bridge server + // receives the correct auth type. + const auth = buildAuthFromEnvOAuthToken(fakeToken); + expect(auth.type).toBe('chatgpt'); + expect((auth as { chatgptAccountId?: string }).chatgptAccountId).toBe('user_oauth_ci'); + + const p = makeProvider({ CODEX_OAUTH_TOKEN: fakeToken }); + const cfg = p.buildSdkConfig('gpt-5.3-codex', { workspacePath: '/tmp/ws-oauth-token' }); + expect(cfg.isAnthropicCompatible).toBe(true); + expect(cfg.envVars.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + p.stopAllBridgeServers(); + }); + + it('warning fires exactly once across multiple getBridgeAuth() callers and buildSdkConfig()', async () => { + // Non-ChatGPT JWT triggers the api_key fallback warning in getBridgeAuth(). + // Every subsequent call — via isAvailable(), getApiKey(), getModels(), or + // buildSdkConfig() — must hit cachedBridgeAuth and NOT re-parse or re-warn. + const subOnlyToken = makeFakeJwt({ sub: 'some-user-id' }); + const warnSpy = spyOn(Logger.prototype, 'warn'); + try { + const p = makeProvider( + { CODEX_OAUTH_TOKEN: subOnlyToken }, + undefined, + undefined, + fakeCodexFound + ); + // Multiple async methods all call getBridgeAuth() internally + await p.isAvailable(); // call 1: parses JWT, emits warning, populates cache + await p.getApiKey(); // call 2: must return from cache, no re-warn + await p.getModels(); // call 3: must return from cache, no re-warn + // Synchronous buildSdkConfig() also must not re-parse + p.buildSdkConfig('gpt-5.3-codex', { workspacePath: '/tmp/no-double-warn-ws' }); + const codexWarnings = warnSpy.mock.calls.filter((args) => + String(args[0]).includes('CODEX_OAUTH_TOKEN') + ); + expect(codexWarnings.length).toBe(1); + p.stopAllBridgeServers(); + } finally { + warnSpy.mockRestore(); + } + }); + it('buildSdkConfig() uses cached API key resolved by prior getApiKey() call', async () => { // Set up a provider with only file-based auth (no env var) const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'neokai-build-cfg-test-')); @@ -377,6 +480,56 @@ describe('AnthropicToCodexBridgeProvider', () => { }); }); + // ------------------------------------------------------------------------- + // buildAuthFromEnvOAuthToken() — auth type selection + // ------------------------------------------------------------------------- + + describe('buildAuthFromEnvOAuthToken()', () => { + it('produces chatgpt auth when chatgpt_account_id is present', () => { + const token = makeFakeJwt({ + 'https://api.openai.com/auth': { + chatgpt_account_id: 'user_abc', + chatgpt_plan_type: 'plus', + }, + }); + const auth = buildAuthFromEnvOAuthToken(token); + expect(auth.type).toBe('chatgpt'); + const a = auth as { chatgptAccountId: string; chatgptPlanType?: string }; + expect(a.chatgptAccountId).toBe('user_abc'); + expect(a.chatgptPlanType).toBe('plus'); + }); + + it('produces api_key auth for sub-only JWT (non-OpenAI)', () => { + // Generic JWT with sub but no OpenAI-specific claim must NOT be chatgpt auth. + const token = makeFakeJwt({ sub: 'some-user-id' }); + const auth = buildAuthFromEnvOAuthToken(token); + expect(auth.type).toBe('api_key'); + expect((auth as { apiKey: string }).apiKey).toBe(token); + }); + + it('produces api_key auth for a plain non-JWT string', () => { + const auth = buildAuthFromEnvOAuthToken('sk-plain-api-key'); + expect(auth.type).toBe('api_key'); + expect((auth as { apiKey: string }).apiKey).toBe('sk-plain-api-key'); + }); + + it('emits a warning when falling back to api_key', () => { + const warnings: string[] = []; + buildAuthFromEnvOAuthToken('not-a-jwt', (msg) => warnings.push(msg)); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('does not contain a ChatGPT account ID'); + }); + + it('does not emit a warning when chatgpt_account_id is present', () => { + const token = makeFakeJwt({ + 'https://api.openai.com/auth': { chatgpt_account_id: 'user_xyz' }, + }); + const warnings: string[] = []; + buildAuthFromEnvOAuthToken(token, (msg) => warnings.push(msg)); + expect(warnings.length).toBe(0); + }); + }); + // ------------------------------------------------------------------------- // ownsModel() // ------------------------------------------------------------------------- @@ -441,6 +594,15 @@ describe('AnthropicToCodexBridgeProvider', () => { expect(models.length).toBeGreaterThan(0); }); + it('returns models when CODEX_OAUTH_TOKEN env var is set', async () => { + const fakeToken = makeFakeJwt({ + 'https://api.openai.com/auth': { chatgpt_account_id: 'user_test_models' }, + }); + provider = makeProvider({ CODEX_OAUTH_TOKEN: fakeToken }, tmpDir, tmpDir, fakeCodexFound); + const models = await provider.getModels(); + expect(models.length).toBeGreaterThan(0); + }); + it('returns models when NeoKai OAuth credentials are in auth.json', async () => { const neokaiDir = path.join(tmpDir, 'neokai'); await writeNeokaiAuth(neokaiDir, { diff --git a/packages/daemon/tests/unit/setup.ts b/packages/daemon/tests/unit/setup.ts index 437fbe0ba..117ad9125 100644 --- a/packages/daemon/tests/unit/setup.ts +++ b/packages/daemon/tests/unit/setup.ts @@ -46,3 +46,4 @@ process.env.ZHIPU_API_KEY = ''; process.env.MINIMAX_API_KEY = ''; process.env.OPENAI_API_KEY = ''; process.env.CODEX_API_KEY = ''; +process.env.CODEX_OAUTH_TOKEN = '';