diff --git a/apps/agent-worker/src/runtime/codex-adapter.ts b/apps/agent-worker/src/runtime/codex-adapter.ts index 332e78d..4f93b5f 100644 --- a/apps/agent-worker/src/runtime/codex-adapter.ts +++ b/apps/agent-worker/src/runtime/codex-adapter.ts @@ -13,7 +13,11 @@ import { streamExecInContainer, } from './docker'; import type { AdapterWorkspace } from './provider'; -import { codexModelArgs, providerEnvironment } from './provider'; +import { + codexModelArgs, + isCodexLoginProfile, + providerEnvironment, +} from './provider'; // Reused verbatim from the worker: normalize + pretty-print an auth JSON blob // before writing it with owner-only permissions. @@ -41,6 +45,11 @@ export const createCodexAdapter: AdapterFactory = (deps) => { const execStream = deps?.execStream ?? streamExecInContainer; const prepareAuth = async (workspace: AdapterWorkspace) => { + // API-key profiles (e.g. OpenAI api_key) authenticate purely via + // OPENAI_API_KEY from providerEnvironment; only ChatGPT-login/JSON-snapshot + // profiles need a Codex `auth.json` on disk. Mirror the Claude adapter, + // which early-returns for non-OAuth profiles. + if (!isCodexLoginProfile(workspace.claim)) return; const secret = workspace.claim.aiProviderProfile?.secret; if (!secret) { throw new Error('Codex auth profile is missing auth.json contents.'); diff --git a/apps/agent-worker/tests/unit/codex-adapter.test.ts b/apps/agent-worker/tests/unit/codex-adapter.test.ts index f1600a8..a5b7761 100644 --- a/apps/agent-worker/tests/unit/codex-adapter.test.ts +++ b/apps/agent-worker/tests/unit/codex-adapter.test.ts @@ -1,9 +1,24 @@ -import { describe, expect, test, vi } from 'vitest'; +import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, test, vi } from 'vitest'; import type { NormalizedAgentEvent } from '../../src/agent-events'; import type { ExecStreamFn } from '../../src/runtime/agent-runtime'; import type { AdapterWorkspace, Claim } from '../../src/runtime/provider'; +const tempDirs: string[] = []; + +const pathExists = async (filePath: string): Promise => { + try { + await stat(filePath); + return true; + } catch { + return false; + } +}; + const loadAdapter = async () => { process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; process.env.GITHUB_APP_ID = '123'; @@ -51,7 +66,88 @@ const makeWorkspace = (): AdapterWorkspace => ({ runtime: 'codex', }); +const makeAuthWorkspace = async ( + profile: Record, +): Promise => { + const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-auth-')); + tempDirs.push(workdir); + const repoDir = path.join(workdir, 'Code', 'spoon'); + await mkdir(repoDir, { recursive: true }); + return { + ...makeWorkspace(), + claim: { ...claim, aiProviderProfile: profile } as unknown as Claim, + workdir, + homeDir: workdir, + repoDir, + }; +}; + describe('CodexAdapter', () => { + afterEach(async () => { + await Promise.all( + tempDirs.map((dir) => rm(dir, { force: true, recursive: true })), + ); + tempDirs.length = 0; + }); + + test('prepareAuth skips auth.json for an api_key profile', async () => { + const { createCodexAdapter } = await loadAdapter(); + const adapter = createCodexAdapter(); + const workspace = await makeAuthWorkspace({ + id: 'p', + name: 'OpenAI', + provider: 'openai', + authType: 'api_key', + model: 'gpt-5', + // A raw API key, which is deliberately NOT valid JSON. + secret: 'sk-test', + }); + + await expect(adapter.prepareAuth?.(workspace)).resolves.toBeUndefined(); + + const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json'); + const openCodeAuthPath = path.join( + workspace.workdir, + '.local', + 'share', + 'opencode', + 'auth.json', + ); + await expect(pathExists(codexAuthPath)).resolves.toBe(false); + await expect(pathExists(openCodeAuthPath)).resolves.toBe(false); + }); + + test('prepareAuth writes auth.json for a ChatGPT-login profile', async () => { + const { createCodexAdapter } = await loadAdapter(); + const adapter = createCodexAdapter(); + const secret = JSON.stringify({ tokens: { access: 'abc' } }); + const workspace = await makeAuthWorkspace({ + id: 'p', + name: 'Codex', + provider: 'opencode_openai_login', + authType: 'opencode_auth_json', + model: 'gpt-5', + secret, + }); + + await adapter.prepareAuth?.(workspace); + + const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json'); + await expect(readFile(codexAuthPath, 'utf8')).resolves.toBe( + `${JSON.stringify(JSON.parse(secret), null, 2)}\n`, + ); + const openCodeAuthPath = path.join( + workspace.workdir, + '.local', + 'share', + 'opencode', + 'auth.json', + ); + await expect(readFile(openCodeAuthPath, 'utf8')).resolves.toBe( + `${JSON.stringify(JSON.parse(secret), null, 2)}\n`, + ); + }); + test('streams normalized events and marks the turn via buildMarkedCommand', async () => { const { createCodexAdapter } = await loadAdapter(); const captured: unknown[] = [];