From b4f39ad4f4be454263afacbfaa7e4a94881f19a9 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:46:59 +0900 Subject: [PATCH] feat(wiring): write Codex OPENAI_API_KEY into auth.json Add applyCodexAuthWiring in src/main/wiring/codexAuth.ts. It merges OPENAI_API_KEY into ~/.codex/auth.json, preserves existing keys, writes atomically via writeJsonAtomic at 0600, and skips with an env note on non-macOS platforms, when file storage is disabled, or when no key is supplied. applyWiring for codex now returns both the config.toml and auth.json results, so the app IPC and the CLI (thin re-export) pick it up with no extra change. --- package.json | 2 +- scripts/test-codex-auth.mjs | 106 +++++++++++++++++++++++++++++++++++ src/main/wiring/codexAuth.ts | 80 ++++++++++++++++++++++++++ src/main/wiring/index.ts | 17 +++++- 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 scripts/test-codex-auth.mjs create mode 100644 src/main/wiring/codexAuth.ts diff --git a/package.json b/package.json index 4e4cc17..097e137 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "prebuild:renderer": "npm run gen:catalog", "catalog:check": "node scripts/check-catalog-stale.mjs", "test": "npm run build:main && npm run test:unit", - "test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs", + "test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs", "test:extractor": "node scripts/test-extract-base-url.mjs", "package": "npm run build && electron-builder" }, diff --git a/scripts/test-codex-auth.mjs b/scripts/test-codex-auth.mjs new file mode 100644 index 0000000..d8c7176 --- /dev/null +++ b/scripts/test-codex-auth.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Unit tests for src/main/wiring/codexAuth.ts (via dist build). + */ +import { createRequire } from 'node:module' +import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const modPath = join(root, 'dist/main/wiring/codexAuth.js') +const wiringPath = join(root, 'dist/main/wiring/index.js') + +if (!existsSync(modPath)) { + console.error('dist/main/wiring/codexAuth.js missing - run npm run build:main first') + process.exit(1) +} + +const require = createRequire(import.meta.url) +const { applyCodexAuthWiring, codexAuthPath, CODEX_AUTH_PLATFORM_NOTE } = require(modPath) +const { applyWiring } = require(wiringPath) + +let failed = 0 +function assert(cond, msg) { + if (cond) console.log(` ✓ ${msg}`) + else { + failed++ + console.error(` ✗ ${msg}`) + } +} + +const home = mkdtempSync(join(tmpdir(), 'hoist-codex-auth-')) +const authPath = codexAuthPath(home) +assert(authPath === join(home, '.codex', 'auth.json'), 'codexAuthPath joins .codex/auth.json') + +// Fresh write on darwin creates the file with the key and 0600 perms. +{ + const r = await applyCodexAuthWiring({ apiKey: 'sk-test-redacted' }, { homeDir: home, platform: 'darwin' }) + assert(r.changed === true && r.path === authPath, 'darwin write reports changed') + const parsed = JSON.parse(readFileSync(authPath, 'utf8')) + assert(parsed.OPENAI_API_KEY === 'sk-test-redacted', 'auth.json contains OPENAI_API_KEY') + if (process.platform !== 'win32') { + assert((statSync(authPath).mode & 0o777) === 0o600, 'auth.json mode is 0600') + } +} + +// Existing keys are preserved and the key is updated. +{ + writeFileSync(authPath, JSON.stringify({ OPENAI_API_KEY: 'old', tokens: { id: 'keep-me' }, other: 1 })) + const r = await applyCodexAuthWiring({ apiKey: 'sk-new' }, { homeDir: home, platform: 'darwin' }) + assert(r.changed === true, 'second write reports changed') + const parsed = JSON.parse(readFileSync(authPath, 'utf8')) + assert(parsed.OPENAI_API_KEY === 'sk-new', 'OPENAI_API_KEY updated') + assert(parsed.tokens?.id === 'keep-me' && parsed.other === 1, 'existing auth.json keys preserved') +} + +// Non-darwin platforms skip with the env note and do not write. +for (const platform of ['linux', 'win32']) { + const h = mkdtempSync(join(tmpdir(), 'hoist-codex-skip-')) + const r = await applyCodexAuthWiring({ apiKey: 'sk-x' }, { homeDir: h, platform }) + assert(r.changed === false && r.note === CODEX_AUTH_PLATFORM_NOTE, `${platform} skips with env note`) + assert(!existsSync(codexAuthPath(h)), `${platform} does not create auth.json`) +} + +// File-storage guard and empty key skip without writing. +{ + const h = mkdtempSync(join(tmpdir(), 'hoist-codex-guard-')) + const r1 = await applyCodexAuthWiring({ apiKey: 'sk-x' }, { homeDir: h, platform: 'darwin', storeInFile: false }) + assert(r1.changed === false && !existsSync(codexAuthPath(h)), 'storeInFile false skips write') + const r2 = await applyCodexAuthWiring({ apiKey: '' }, { homeDir: h, platform: 'darwin' }) + assert(r2.changed === false && !existsSync(codexAuthPath(h)), 'empty api key skips write') +} + +// applyWiring for codex returns both the config.toml and auth.json results. +{ + const h = mkdtempSync(join(tmpdir(), 'hoist-codex-wire-')) + mkdirSync(join(h, '.codex'), { recursive: true }) + const prevHome = process.env.HOME + process.env.HOME = h + try { + const results = await applyWiring({ + apiKey: 'sk-wire', + baseUrl: 'https://example.invalid/v1', + harness: { id: 'codex', label: 'Codex', installMethods: [] }, + provider: { id: 'openai', label: 'OpenAI', authType: 'api_key', probeKind: 'openaiModels' }, + gateway: null, + homeDir: h, + platform: 'darwin', + }) + assert(results.length === 2, 'applyWiring codex returns two results') + assert(results.some((r) => r.path.endsWith('auth.json') && r.changed), 'applyWiring includes changed auth.json result') + assert(results.some((r) => r.path.endsWith('config.toml')), 'applyWiring includes config.toml result') + const parsed = JSON.parse(readFileSync(join(h, '.codex', 'auth.json'), 'utf8')) + assert(parsed.OPENAI_API_KEY === 'sk-wire', 'applyWiring wrote key into auth.json') + } finally { + if (prevHome === undefined) delete process.env.HOME + else process.env.HOME = prevHome + } +} + +if (failed > 0) { + console.error(`\n${failed} codex-auth assertion(s) failed`) + process.exit(1) +} +console.log('\nall codex-auth cases passed') diff --git a/src/main/wiring/codexAuth.ts b/src/main/wiring/codexAuth.ts new file mode 100644 index 0000000..fc7b4ae --- /dev/null +++ b/src/main/wiring/codexAuth.ts @@ -0,0 +1,80 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { readJsonOrNull, writeJsonAtomic } from '../fsutil' + +export function codexAuthPath(homeDir: string = homedir()): string { + return join(homeDir, '.codex', 'auth.json') +} + +export interface CodexAuthShape { + OPENAI_API_KEY?: string + [key: string]: unknown +} + +export interface CodexAuthWiring { + apiKey: string +} + +export interface CodexAuthOptions { + homeDir?: string + platform?: NodeJS.Platform + /** When false, the caller has not enabled file storage for credentials. */ + storeInFile?: boolean +} + +export interface CodexAuthResult { + path: string + changed: boolean + note?: string +} + +export const CODEX_AUTH_PLATFORM_NOTE = + 'Codex on this platform reads OPENAI_API_KEY from env; please set it in your shell' + +/** + * Write OPENAI_API_KEY into ~/.codex/auth.json so Codex works from a fresh + * shell without the env var set. Existing keys in the file are preserved. + * The file is written atomically with 0600 permissions via writeJsonAtomic. + * + * On non-macOS platforms the write is skipped per the wiring contract for + * this slice. The caller surfaces the returned note instead. + */ +export async function applyCodexAuthWiring( + wiring: CodexAuthWiring, + opts: CodexAuthOptions = {}, +): Promise { + const path = codexAuthPath(opts.homeDir) + const platform = opts.platform ?? process.platform + + if (platform !== 'darwin') { + return { path, changed: false, note: CODEX_AUTH_PLATFORM_NOTE } + } + + if (opts.storeInFile === false) { + return { + path, + changed: false, + note: 'File storage is not enabled; Codex reads OPENAI_API_KEY from env; please set it in your shell', + } + } + + if (!wiring.apiKey) { + return { path, changed: false, note: 'No API key supplied; skipped Codex auth.json write.' } + } + + const existingRaw = await readJsonOrNull(path) + const existing: CodexAuthShape = + existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) + ? (existingRaw as CodexAuthShape) + : {} + + const next: CodexAuthShape = { ...existing, OPENAI_API_KEY: wiring.apiKey } + await writeJsonAtomic(path, next) + return { path, changed: true } +} + +export async function readCodexAuthShape( + homeDir?: string, +): Promise { + return ((await readJsonOrNull(codexAuthPath(homeDir))) as CodexAuthShape | null) ?? null +} diff --git a/src/main/wiring/index.ts b/src/main/wiring/index.ts index f381d56..493e2b1 100644 --- a/src/main/wiring/index.ts +++ b/src/main/wiring/index.ts @@ -3,11 +3,21 @@ import type { ProviderEntry } from '../providers/types' import type { ToolInstallSpec } from '../../shared/types' import { applyClaudeCodeWiring, claudeCodeEnvFor } from './claudeCode' import { applyCodexWiring } from './codex' +import { applyCodexAuthWiring } from './codexAuth' import { applyOpenCodeWiring } from './openCode' +export { applyCodexAuthWiring, codexAuthPath, CODEX_AUTH_PLATFORM_NOTE } from './codexAuth' +export type { CodexAuthOptions, CodexAuthResult, CodexAuthShape, CodexAuthWiring } from './codexAuth' + export interface WireOptions { /** Vault-stored API key for the chosen provider. */ apiKey: string + /** When false, skip writing credentials to harness files on disk. */ + storeInFile?: boolean + /** Platform override for the Codex auth.json writer (tests). */ + platform?: NodeJS.Platform + /** Home directory override for the Codex auth.json writer (tests). */ + homeDir?: string /** Effective base URL after gateway resolution. */ baseUrl: string /** Short harness id (matches HARNESS_CATALOG). */ @@ -39,7 +49,12 @@ export async function applyWiring(opts: WireOptions): Promise { out.push({ ...r, envHint: wiring.env }) } else if (harness.id === 'codex') { const r = await applyCodexWiring({ baseUrl }, 'openai', 'gpt-4o') - out.push({ ...r, note: 'Codex reads OPENAI_API_KEY from auth.json or env.' }) + out.push({ ...r }) + const auth = await applyCodexAuthWiring( + { apiKey }, + { homeDir: opts.homeDir, platform: opts.platform, storeInFile: opts.storeInFile }, + ) + out.push({ path: auth.path, changed: auth.changed, note: auth.note }) } else if (harness.id === 'opencode') { const adapter = provider.probeKind === 'anthropicModels' ? '@ai-sdk/anthropic' : '@ai-sdk/openai-compatible'