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'