Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
106 changes: 106 additions & 0 deletions scripts/test-codex-auth.mjs
Original file line number Diff line number Diff line change
@@ -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')
80 changes: 80 additions & 0 deletions src/main/wiring/codexAuth.ts
Original file line number Diff line number Diff line change
@@ -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<CodexAuthResult> {
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<CodexAuthShape | null> {
return ((await readJsonOrNull(codexAuthPath(homeDir))) as CodexAuthShape | null) ?? null
}
17 changes: 16 additions & 1 deletion src/main/wiring/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -39,7 +49,12 @@ export async function applyWiring(opts: WireOptions): Promise<WireResult[]> {
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'
Expand Down
Loading