Skip to content

Typescript SDK auth doesn't seemed to be working #7144

@eyal-lantzman

Description

@eyal-lantzman

What version of Codex is running?

0.55.0

What subscription do you have?

enterprise

Which model were you using?

gpt-5

What platform is your computer?

Darwin 24.6.0 arm64 arm

What issue are you seeing?

When authenticating using codex-sdk (passing API key arg or setting CODEX_API_KEY with access key), i'm getting 401 Unauthorized, request id: 9a28811dbdadedbd-LHR

Request id obviously changes...

I don't have any ways to debugs this but i made sure to refresh the token and use a fresh access token before calling thread.runStreamed(..., options)

What steps can reproduce the bug?

See my codexAgentService.ts and call runCodexTurn after setting the env variable or logging in and reusing the token that was generated.

import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { Buffer } from 'node:buffer';

import {
  Codex,
  type CodexOptions,
  type Input,
  type RunResult,
  type RunStreamedResult,
  type ThreadOptions,
  type TurnOptions,
  type ThreadEvent,
  type Usage
} from '@openai/codex-sdk';

import type { BackendConfig } from '../config/index.js';
import type { CatalogService } from './catalogService.js';
import { AppError } from '../lib/errors.js';
import type { CodingAgentProvider } from '../models/codingAgentProvider.js';
import {
  GenericAgentService,
  type AgentRequest,
  type AgentResponse,
  type AgentInput,
  type AgentInputItem
} from './genericAgentService.js';
import { configureCodexHome } from '../lib/codexConfig.js';

export interface CodexThread {
  id: string | null;
  run(input: Input, options?: TurnOptions): Promise<RunResult>;
  runStreamed(input: Input, options?: TurnOptions): Promise<RunStreamedResult>;
}

type CodexUserInput = Exclude<Input, string> extends Array<infer T> ? T : never;

export interface CodexClient {
  startThread(options?: ThreadOptions): CodexThread;
  resumeThread(id: string, options?: ThreadOptions): CodexThread;
}

interface CodexAgentDependencies {
  codexFactory?: (options: CodexOptions) => CodexClient;
}

let authWarningIssued = false;
const TOKEN_REFRESH_THRESHOLD_SECONDS = 300;
const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
const TOKEN_ENDPOINT = 'https://auth.openai.com/oauth/token';

export class CodexAgentService extends GenericAgentService {
  private apiKey?: string;
  private apiKeySource?: 'env' | 'auth';
  private readonly codexFactory: (options: CodexOptions) => CodexClient;

  constructor(
    private readonly config: BackendConfig,
    catalogService: CatalogService,
    dependencies: CodexAgentDependencies = {}
  ) {
    super(catalogService);
    configureCodexHome(config);
    const envKey = process.env.CODEX_API_KEY?.trim();
    if (envKey) {
      this.apiKey = envKey;
      this.apiKeySource = 'env';
    }
    this.codexFactory = dependencies.codexFactory ?? ((options) => new Codex(options));
  }

  protected getProviderKey(): string {
    return 'codex';
  }

  async sendMessage(request: AgentRequest): Promise<AgentResponse> {
    const provider = await this.getProvider();
    return this.sendWithAuthRetry(provider, request);
  }

  private async sendWithAuthRetry(
    provider: CodingAgentProvider,
    request: AgentRequest
  ): Promise<AgentResponse> {
    for (let attempt = 0; attempt < 2; attempt += 1) {
      const client = await this.createClient(provider);
      if (!client) {
        return { content: '[codex unavailable] Auto-response disabled' } satisfies AgentResponse;
      }

      const threadOptions = this.buildThreadOptions(provider);
      const thread = request.metadata.providerThreadId
        ? client.resumeThread(request.metadata.providerThreadId, threadOptions)
        : client.startThread(threadOptions);

      const agentInput = this.toAgentInput(request.content);
      const codexInput = this.normalizeCodexInput(agentInput);

      try {
        const { responseText } = await this.runCodexTurn(thread, codexInput, request.options);
        return {
          content: responseText,
          providerThreadId: thread.id ?? request.metadata.providerThreadId ?? null
        } satisfies AgentResponse;
      } catch (error) {
        const canRetry =
          attempt === 0 &&
          this.shouldRetryAfterUnauthorized(error) &&
          (await this.tryRefreshAuthToken());
        if (!canRetry) {
          throw error;
        }
      }
    }
    throw new AppError('Codex retry attempts exhausted', 502, 'PROVIDER_ERROR');
  }

  private async createClient(provider: CodingAgentProvider): Promise<CodexClient | undefined> {
    const apiKey = await this.getApiKey();
    if (!apiKey) {
      console.warn(
        '[codex] No API key or auth token available. Ensure CODEX_API_KEY is set or run `make codex-login` so CODEX_HOME/auth.json contains a valid credential.'
      );
      return undefined;
    }
    const baseUrl = provider.config?.baseUrl;
    const normalizedBaseUrl =
      typeof baseUrl === 'string' && baseUrl.trim().length > 0 ? baseUrl.trim() : undefined;

    const options: CodexOptions = {
      apiKey
    };

    if (normalizedBaseUrl) {
      options.baseUrl = normalizedBaseUrl;
    }

    return this.codexFactory(options);
  }

  private async tryRefreshAuthToken(): Promise<boolean> {
    if (this.apiKeySource === 'env') {
      return false;
    }
    const refreshed = await this.loadApiKeyFromAuthFile(true);
    if (!refreshed) {
      console.warn('[codex] Didn\'t refresh token due to 401.');
      return false;
    }
    this.apiKey = refreshed;
    this.apiKeySource = 'auth';
    console.info('[codex] Retrying Codex request after refreshing token due to 401.');
    return true;
  }

  private async getApiKey(): Promise<string | undefined> {
    if (this.apiKey && this.apiKeySource !== 'auth') {
      return this.apiKey;
    }
    if (this.apiKey && this.apiKeySource === 'auth' && !shouldRefreshToken(this.apiKey)) {
      return this.apiKey;
    }
    const refreshed = await this.resolveApiKey();
    this.apiKey = refreshed;
    return this.apiKey;
  }

  private async resolveApiKey(): Promise<string | undefined> {
    const envKey = process.env.CODEX_API_KEY?.trim();
    if (envKey) {
      this.apiKeySource = 'env';
      return envKey;
    }
    const fileKey = await this.loadApiKeyFromAuthFile();
    if (fileKey) {
      this.apiKeySource = 'auth';
    }
    return fileKey;
  }

  private buildThreadOptions(provider: CodingAgentProvider): ThreadOptions {
    const options: ThreadOptions = {
      workingDirectory: this.config.workspaceRoot
    } satisfies ThreadOptions;

    const model = provider.config?.model;
    if (typeof model === 'string' && model.trim() !== '') {
      options.model = model.trim();
    }

    const sandboxMode = provider.config?.sandboxMode;
    if (
      sandboxMode === 'read-only' ||
      sandboxMode === 'workspace-write' ||
      sandboxMode === 'danger-full-access'
    ) {
      options.sandboxMode = sandboxMode;
    }

    const skipGitRepoCheck = provider.config?.skipGitRepoCheck;
    if (typeof skipGitRepoCheck === 'boolean') {
      options.skipGitRepoCheck = skipGitRepoCheck;
    }

    return options;
  }

  private async runCodexTurn(
    thread: CodexThread,
    input: Input,
    options?: AgentRequest['options']
  ): Promise<{ responseText: string; usage?: Usage }> {
    if (typeof input === 'string') {
      if (!input.trim()) {
        throw new AppError('Prompt must not be empty', 400, 'PROVIDER_ERROR');
      }
    } else {
      const hasContent = input.some((item) => {
        if (item.type === 'text') {
          return item.text.trim().length > 0;
        }
        return true;
      });
      if (!hasContent) {
        throw new AppError('Prompt must not be empty', 400, 'PROVIDER_ERROR');
      }
    }

    const mode = options?.mode ?? 'stream';
    const turnOptions = this.toTurnOptions(options);

    if (mode === 'buffer') {
      const result = await thread.run(input, turnOptions);
      if (!result.finalResponse?.trim()) {
        throw new AppError('Codex returned empty response', 502, 'PROVIDER_ERROR');
      }
      return { responseText: result.finalResponse.trim(), usage: result.usage ?? undefined };
    }

    const { events } = await thread.runStreamed(input, turnOptions);

    let finalResponse: string | undefined;
    let usage: Usage | undefined;

    for await (const event of events) {
      options?.onEvent?.(event);
      if (event.type === 'item.completed') {
        if (event.item.type === 'agent_message') {
          finalResponse = event.item.text.trim();
        } else if (event.item.type === 'error') {
          const message = `Codex reported error: ${event.item.message}`;
          maybeLogAuthTroubleshooting(message);
          throw new AppError(message, 502, 'PROVIDER_ERROR');
        }
      } else if (event.type === 'turn.completed') {
        usage = event.usage;
      } else if (event.type === 'turn.failed') {
        const message = `Codex turn failed: ${event.error.message}`;
        maybeLogAuthTroubleshooting(message);
        throw new AppError(message, 502, 'PROVIDER_ERROR');
      } else if (event.type === 'error') {
        if (!isTransientStreamError(event.message)) {
          const message = `Codex stream error: ${event.message}`;
          maybeLogAuthTroubleshooting(message);
          throw new AppError(message, 502, 'PROVIDER_ERROR');
        }
      }
    }

    if (!finalResponse) {
      throw new AppError('Codex returned empty response', 502, 'PROVIDER_ERROR');
    }

    return { responseText: finalResponse, usage };
  }

  private shouldRetryAfterUnauthorized(error: unknown): boolean {
    if (!error) {
      return false;
    }
    if (error instanceof AppError) {
      return isUnauthorizedMessage(error.message);
    }
    if (error instanceof Error) {
      return isUnauthorizedMessage(error.message);
    }
    return false;
  }

  private toTurnOptions(options?: AgentRequest['options']): TurnOptions | undefined {
    if (!options?.outputSchema) {
      return undefined;
    }
    return { outputSchema: options.outputSchema } satisfies TurnOptions;
  }

  private normalizeCodexInput(content: AgentInput): Input {
    if (typeof content === 'string') {
      return content;
    }
    return content.map((item) => this.normalizeCodexItem(item));
  }

  private normalizeCodexItem(item: AgentInputItem): CodexUserInput {
    if (item.type === 'text') {
      return { type: 'text', text: item.text } as const;
    }
    if (item.type === 'local_image') {
      return { type: 'local_image', path: item.path } as const;
    }
    const caption = item.alt?.trim();
    const description = caption ? `${caption}: ${item.url}` : item.url;
    return { type: 'text', text: description } as const;
  }

  private async loadApiKeyFromAuthFile(forceRefresh = false): Promise<string | undefined> {
    const codexHome = process.env.CODEX_HOME?.trim();
    const baseDir = codexHome ?? join(homedir(), '.codex');
    const authFilePath = join(baseDir, 'auth.json');

    try {
      const raw = readFileSync(authFilePath, 'utf8');
      const parsed = JSON.parse(raw);
      let token: string | undefined =
        typeof parsed?.tokens?.access_token === 'string' ? parsed.tokens.access_token.trim() : undefined;
      const needsRefresh = forceRefresh || (token ? shouldRefreshToken(token) : false);
      if (needsRefresh) {
        const refreshed = await refreshAccessTokenRequest(parsed?.tokens?.refresh_token);
        if (refreshed?.access_token) {
          parsed.tokens.access_token = refreshed.access_token;
          if (refreshed.refresh_token) {
            parsed.tokens.refresh_token = refreshed.refresh_token;
          }
          if (refreshed.id_token) {
            parsed.tokens.id_token = refreshed.id_token;
          }
          parsed.last_refresh = new Date().toISOString();
          writeFileSync(authFilePath, JSON.stringify(parsed, null, 2));
          token = refreshed.access_token;
          console.info('[codex] Refreshed Codex OAuth access token.');
        } else {
          console.warn('[codex] Token refresh response missing access_token.');
        }
      }
      if (token) {
          console.info(`[codex] Using token from: ${parsed.last_refresh}`);
        return token;
      }
      if (!authWarningIssued) {
        console.warn(
          `[codex] Access token missing in ${authFilePath}. Run 'codex login' and ensure CODEX_HOME points to the refreshed directory.`
        );
        authWarningIssued = true;
      }
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && !authWarningIssued) {
        console.warn(
          `[codex] Failed to read ${authFilePath}. Run 'codex login' or update CODEX_HOME.`,
          error
        );
        authWarningIssued = true;
      }
    }
    return undefined;
  }
}

function isTransientStreamError(message?: string): boolean {
  if (!message) {
    return false;
  }
  const normalized = message.toLowerCase();
  return normalized.includes('re-connecting');
}

function shouldRefreshToken(token: string): boolean {
  const parts = token.split('.');
  if (parts.length < 2) {
    return false;
  }
  try {
    const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
    const exp = typeof payload.exp === 'number' ? payload.exp : undefined;
    if (!exp) {
      return false;
    }
    const now = Math.floor(Date.now() / 1000);
    return exp - now <= TOKEN_REFRESH_THRESHOLD_SECONDS;
  } catch {
    return false;
  }
}

async function refreshAccessTokenRequest(refreshToken?: string): Promise<{
  access_token: string;
  refresh_token?: string;
  id_token?: string;
} | undefined> {
  if (!refreshToken) {
    console.warn('[codex] Missing refresh token; unable to refresh Codex credentials.');
    return undefined;
  }
  try {
    const response = await fetch(TOKEN_ENDPOINT, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: process.env.CODEX_CLIENT_ID ?? DEFAULT_CODEX_CLIENT_ID
      })
    });
    if (!response.ok) {
      console.warn('[codex] Failed to refresh Codex token', {
        status: response.status,
        statusText: response.statusText
      });
      return undefined;
    }
    const data = (await response.json()) as {
      access_token?: string;
      refresh_token?: string;
      id_token?: string;
    };
    if (!data.access_token) {
      console.warn('[codex] Token refresh response missing access_token.');
      return undefined;
    }
    return {
      access_token: data.access_token.trim(),
      refresh_token: data.refresh_token?.trim(),
      id_token: data.id_token?.trim()
    };
  } catch (error) {
    console.warn('[codex] Error refreshing Codex credentials', error);
    return undefined;
  }

}

function maybeLogAuthTroubleshooting(message?: string): boolean {
  if (!isUnauthorizedMessage(message)) {
    return false;
  }
  const codexHome = process.env.CODEX_HOME ?? '(not set)';
  console.warn(
    '[codex-auth] Received 401 from Codex. Run "codex login" to refresh credentials, ' +
      'ensure CODEX_HOME points to the repo-local auth directory, and rerun "make backend-dev".',
    { codexHome }
  );
  return true;
}

function isUnauthorizedMessage(message?: string): boolean {
  if (!message) {
    return false;
  }
  const normalized = message.toLowerCase();
  return normalized.includes('401') || normalized.includes('unauthorized');
}

What is the expected behavior?

The auth works e.g. sending 1+1 should return 2

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsdkIssues related to the Codex SDK

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions