Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
26 changes: 26 additions & 0 deletions packages/daemon/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<jwt-from-codex-login>
# CODEX_API_KEY=sk-...

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# GitHub Copilot provider (anthropic-copilot)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown> | 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<string, string> | 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.
Expand Down Expand Up @@ -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<AppServerAuth | undefined> {
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<AppServerAuth, { type: 'chatgpt' }>).accessToken;
return auth;
}
if (this.env.OPENAI_API_KEY) {
return { type: 'api_key', apiKey: this.env.OPENAI_API_KEY };
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -14,7 +14,7 @@
* Codex API.
*
* Run with:
* OPENAI_API_KEY=sk-xxx bun test \
* CODEX_OAUTH_TOKEN=<oauth-token> bun test \
* packages/daemon/tests/online/providers/anthropic-to-codex-bridge-provider.test.ts
*/

Expand Down Expand Up @@ -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`.'
);
}

Expand Down
Loading
Loading