diff --git a/src/__tests__/codex-structured-output.test.ts b/src/__tests__/codex-structured-output.test.ts index a7a6a28..838f570 100644 --- a/src/__tests__/codex-structured-output.test.ts +++ b/src/__tests__/codex-structured-output.test.ts @@ -15,10 +15,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; let mockEvents: Array> = []; let lastThreadOptions: Record | undefined; +let lastCodexConstructorOptions: Record | undefined; vi.mock('@openai/codex-sdk', () => { return { Codex: class MockCodex { + constructor(options?: Record) { + lastCodexConstructorOptions = options; + } async startThread(options?: Record) { lastThreadOptions = options; return { @@ -47,6 +51,7 @@ describe('CodexClient — structuredOutput 抽出', () => { vi.clearAllMocks(); mockEvents = []; lastThreadOptions = undefined; + lastCodexConstructorOptions = undefined; }); it('outputSchema 指定時に agent_message の JSON テキストを structuredOutput として返す', async () => { @@ -169,4 +174,21 @@ describe('CodexClient — structuredOutput 抽出', () => { networkAccessEnabled: true, }); }); + + it('codexPathOverride が Codex constructor options に反映される', async () => { + mockEvents = [ + { type: 'thread.started', thread_id: 'thread-1' }, + { type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } }, + ]; + + const client = new CodexClient(); + await client.call('coder', 'prompt', { + cwd: '/tmp', + codexPathOverride: '/opt/codex/bin/codex', + }); + + expect(lastCodexConstructorOptions).toMatchObject({ + codexPathOverride: '/opt/codex/bin/codex', + }); + }); }); diff --git a/src/__tests__/apiKeyAuth.test.ts b/src/__tests__/globalConfig-resolvers.test.ts similarity index 69% rename from src/__tests__/apiKeyAuth.test.ts rename to src/__tests__/globalConfig-resolvers.test.ts index f21f5db..9f211cc 100644 --- a/src/__tests__/apiKeyAuth.test.ts +++ b/src/__tests__/globalConfig-resolvers.test.ts @@ -1,344 +1,469 @@ -/** - * Tests for API key authentication feature - * - * Tests the resolution logic for Anthropic and OpenAI API keys: - * - Environment variable priority over config.yaml - * - Config.yaml fallback when env var is not set - * - Undefined when neither is set - * - Schema validation for API key fields - * - GlobalConfig load/save round-trip with API keys - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { GlobalConfigSchema } from '../core/models/index.js'; - -// Mock paths module to redirect config to temp directory -const testId = randomUUID(); -const testDir = join(tmpdir(), `takt-api-key-test-${testId}`); -const taktDir = join(testDir, '.takt'); -const configPath = join(taktDir, 'config.yaml'); - -vi.mock('../infra/config/paths.js', async (importOriginal) => { - const original = await importOriginal() as Record; - return { - ...original, - getGlobalConfigPath: () => configPath, - getTaktDir: () => taktDir, - }; -}); - -// Import after mocking -const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); - -describe('GlobalConfigSchema API key fields', () => { - it('should accept config without API keys', () => { - const result = GlobalConfigSchema.parse({ - language: 'en', - }); - expect(result.anthropic_api_key).toBeUndefined(); - expect(result.openai_api_key).toBeUndefined(); - }); - - it('should accept config with anthropic_api_key', () => { - const result = GlobalConfigSchema.parse({ - language: 'en', - anthropic_api_key: 'sk-ant-test-key', - }); - expect(result.anthropic_api_key).toBe('sk-ant-test-key'); - }); - - it('should accept config with openai_api_key', () => { - const result = GlobalConfigSchema.parse({ - language: 'en', - openai_api_key: 'sk-openai-test-key', - }); - expect(result.openai_api_key).toBe('sk-openai-test-key'); - }); - - it('should accept config with both API keys', () => { - const result = GlobalConfigSchema.parse({ - language: 'en', - anthropic_api_key: 'sk-ant-key', - openai_api_key: 'sk-openai-key', - }); - expect(result.anthropic_api_key).toBe('sk-ant-key'); - expect(result.openai_api_key).toBe('sk-openai-key'); - }); -}); - -describe('GlobalConfig load/save with API keys', () => { - beforeEach(() => { - invalidateGlobalConfigCache(); - mkdirSync(taktDir, { recursive: true }); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should load config with API keys from YAML', () => { - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - 'anthropic_api_key: sk-ant-from-yaml', - 'openai_api_key: sk-openai-from-yaml', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const config = loadGlobalConfig(); - expect(config.anthropicApiKey).toBe('sk-ant-from-yaml'); - expect(config.openaiApiKey).toBe('sk-openai-from-yaml'); - }); - - it('should load config without API keys', () => { - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const config = loadGlobalConfig(); - expect(config.anthropicApiKey).toBeUndefined(); - expect(config.openaiApiKey).toBeUndefined(); - }); - - it('should save and reload config with API keys', () => { - // Write initial config - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const config = loadGlobalConfig(); - config.anthropicApiKey = 'sk-ant-saved'; - config.openaiApiKey = 'sk-openai-saved'; - saveGlobalConfig(config); - - const reloaded = loadGlobalConfig(); - expect(reloaded.anthropicApiKey).toBe('sk-ant-saved'); - expect(reloaded.openaiApiKey).toBe('sk-openai-saved'); - }); - - it('should not persist API keys when not set', () => { - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const config = loadGlobalConfig(); - saveGlobalConfig(config); - - const content = readFileSync(configPath, 'utf-8'); - expect(content).not.toContain('anthropic_api_key'); - expect(content).not.toContain('openai_api_key'); - }); -}); - -describe('resolveAnthropicApiKey', () => { - const originalEnv = process.env['TAKT_ANTHROPIC_API_KEY']; - - beforeEach(() => { - invalidateGlobalConfigCache(); - mkdirSync(taktDir, { recursive: true }); - }); - - afterEach(() => { - if (originalEnv !== undefined) { - process.env['TAKT_ANTHROPIC_API_KEY'] = originalEnv; - } else { - delete process.env['TAKT_ANTHROPIC_API_KEY']; - } - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should return env var when set', () => { - process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env'; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - 'anthropic_api_key: sk-ant-from-yaml', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveAnthropicApiKey(); - expect(key).toBe('sk-ant-from-env'); - }); - - it('should fall back to config when env var is not set', () => { - delete process.env['TAKT_ANTHROPIC_API_KEY']; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - 'anthropic_api_key: sk-ant-from-yaml', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveAnthropicApiKey(); - expect(key).toBe('sk-ant-from-yaml'); - }); - - it('should return undefined when neither env var nor config is set', () => { - delete process.env['TAKT_ANTHROPIC_API_KEY']; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveAnthropicApiKey(); - expect(key).toBeUndefined(); - }); - - it('should return undefined when config file does not exist', () => { - delete process.env['TAKT_ANTHROPIC_API_KEY']; - // No config file created - rmSync(testDir, { recursive: true, force: true }); - - const key = resolveAnthropicApiKey(); - expect(key).toBeUndefined(); - }); -}); - -describe('resolveOpenaiApiKey', () => { - const originalEnv = process.env['TAKT_OPENAI_API_KEY']; - - beforeEach(() => { - invalidateGlobalConfigCache(); - mkdirSync(taktDir, { recursive: true }); - }); - - afterEach(() => { - if (originalEnv !== undefined) { - process.env['TAKT_OPENAI_API_KEY'] = originalEnv; - } else { - delete process.env['TAKT_OPENAI_API_KEY']; - } - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should return env var when set', () => { - process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env'; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - 'openai_api_key: sk-openai-from-yaml', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveOpenaiApiKey(); - expect(key).toBe('sk-openai-from-env'); - }); - - it('should fall back to config when env var is not set', () => { - delete process.env['TAKT_OPENAI_API_KEY']; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - 'openai_api_key: sk-openai-from-yaml', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveOpenaiApiKey(); - expect(key).toBe('sk-openai-from-yaml'); - }); - - it('should return undefined when neither env var nor config is set', () => { - delete process.env['TAKT_OPENAI_API_KEY']; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveOpenaiApiKey(); - expect(key).toBeUndefined(); - }); -}); - -describe('resolveOpencodeApiKey', () => { - const originalEnv = process.env['TAKT_OPENCODE_API_KEY']; - - beforeEach(() => { - invalidateGlobalConfigCache(); - mkdirSync(taktDir, { recursive: true }); - }); - - afterEach(() => { - if (originalEnv !== undefined) { - process.env['TAKT_OPENCODE_API_KEY'] = originalEnv; - } else { - delete process.env['TAKT_OPENCODE_API_KEY']; - } - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should return env var when set', () => { - process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env'; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - 'opencode_api_key: sk-opencode-from-yaml', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveOpencodeApiKey(); - expect(key).toBe('sk-opencode-from-env'); - }); - - it('should fall back to config when env var is not set', () => { - delete process.env['TAKT_OPENCODE_API_KEY']; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - 'opencode_api_key: sk-opencode-from-yaml', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveOpencodeApiKey(); - expect(key).toBe('sk-opencode-from-yaml'); - }); - - it('should return undefined when neither env var nor config is set', () => { - delete process.env['TAKT_OPENCODE_API_KEY']; - const yaml = [ - 'language: en', - 'default_piece: default', - 'log_level: info', - 'provider: claude', - ].join('\n'); - writeFileSync(configPath, yaml, 'utf-8'); - - const key = resolveOpencodeApiKey(); - expect(key).toBeUndefined(); - }); -}); +/** + * Tests for API key authentication feature + * + * Tests the resolution logic for Anthropic and OpenAI API keys: + * - Environment variable priority over config.yaml + * - Config.yaml fallback when env var is not set + * - Undefined when neither is set + * - Schema validation for API key fields + * - GlobalConfig load/save round-trip with API keys + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { GlobalConfigSchema } from '../core/models/index.js'; + +// Mock paths module to redirect config to temp directory +const testId = randomUUID(); +const testDir = join(tmpdir(), `takt-api-key-test-${testId}`); +const taktDir = join(testDir, '.takt'); +const configPath = join(taktDir, 'config.yaml'); + +function createExecutableFile(filename: string): string { + const filePath = join(testDir, filename); + writeFileSync(filePath, '#!/bin/sh\necho codex\n', 'utf-8'); + chmodSync(filePath, 0o755); + return filePath; +} + +function createNonExecutableFile(filename: string): string { + const filePath = join(testDir, filename); + writeFileSync(filePath, '#!/bin/sh\necho codex\n', 'utf-8'); + chmodSync(filePath, 0o644); + return filePath; +} + +vi.mock('../infra/config/paths.js', async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + getGlobalConfigPath: () => configPath, + getTaktDir: () => taktDir, + }; +}); + +// Import after mocking +const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveCodexCliPath, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); + +describe('GlobalConfigSchema API key fields', () => { + it('should accept config without API keys', () => { + const result = GlobalConfigSchema.parse({ + language: 'en', + }); + expect(result.anthropic_api_key).toBeUndefined(); + expect(result.openai_api_key).toBeUndefined(); + }); + + it('should accept config with anthropic_api_key', () => { + const result = GlobalConfigSchema.parse({ + language: 'en', + anthropic_api_key: 'sk-ant-test-key', + }); + expect(result.anthropic_api_key).toBe('sk-ant-test-key'); + }); + + it('should accept config with openai_api_key', () => { + const result = GlobalConfigSchema.parse({ + language: 'en', + openai_api_key: 'sk-openai-test-key', + }); + expect(result.openai_api_key).toBe('sk-openai-test-key'); + }); + + it('should accept config with both API keys', () => { + const result = GlobalConfigSchema.parse({ + language: 'en', + anthropic_api_key: 'sk-ant-key', + openai_api_key: 'sk-openai-key', + }); + expect(result.anthropic_api_key).toBe('sk-ant-key'); + expect(result.openai_api_key).toBe('sk-openai-key'); + }); +}); + +describe('GlobalConfig load/save with API keys', () => { + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should load config with API keys from YAML', () => { + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'anthropic_api_key: sk-ant-from-yaml', + 'openai_api_key: sk-openai-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const config = loadGlobalConfig(); + expect(config.anthropicApiKey).toBe('sk-ant-from-yaml'); + expect(config.openaiApiKey).toBe('sk-openai-from-yaml'); + }); + + it('should load config without API keys', () => { + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const config = loadGlobalConfig(); + expect(config.anthropicApiKey).toBeUndefined(); + expect(config.openaiApiKey).toBeUndefined(); + }); + + it('should save and reload config with API keys', () => { + // Write initial config + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const config = loadGlobalConfig(); + config.anthropicApiKey = 'sk-ant-saved'; + config.openaiApiKey = 'sk-openai-saved'; + saveGlobalConfig(config); + + const reloaded = loadGlobalConfig(); + expect(reloaded.anthropicApiKey).toBe('sk-ant-saved'); + expect(reloaded.openaiApiKey).toBe('sk-openai-saved'); + }); + + it('should not persist API keys when not set', () => { + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const config = loadGlobalConfig(); + saveGlobalConfig(config); + + const content = readFileSync(configPath, 'utf-8'); + expect(content).not.toContain('anthropic_api_key'); + expect(content).not.toContain('openai_api_key'); + }); +}); + +describe('resolveAnthropicApiKey', () => { + const originalEnv = process.env['TAKT_ANTHROPIC_API_KEY']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_ANTHROPIC_API_KEY'] = originalEnv; + } else { + delete process.env['TAKT_ANTHROPIC_API_KEY']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var when set', () => { + process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env'; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'anthropic_api_key: sk-ant-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveAnthropicApiKey(); + expect(key).toBe('sk-ant-from-env'); + }); + + it('should fall back to config when env var is not set', () => { + delete process.env['TAKT_ANTHROPIC_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'anthropic_api_key: sk-ant-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveAnthropicApiKey(); + expect(key).toBe('sk-ant-from-yaml'); + }); + + it('should return undefined when neither env var nor config is set', () => { + delete process.env['TAKT_ANTHROPIC_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveAnthropicApiKey(); + expect(key).toBeUndefined(); + }); + + it('should return undefined when config file does not exist', () => { + delete process.env['TAKT_ANTHROPIC_API_KEY']; + // No config file created + rmSync(testDir, { recursive: true, force: true }); + + const key = resolveAnthropicApiKey(); + expect(key).toBeUndefined(); + }); +}); + +describe('resolveOpenaiApiKey', () => { + const originalEnv = process.env['TAKT_OPENAI_API_KEY']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_OPENAI_API_KEY'] = originalEnv; + } else { + delete process.env['TAKT_OPENAI_API_KEY']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var when set', () => { + process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env'; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'openai_api_key: sk-openai-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpenaiApiKey(); + expect(key).toBe('sk-openai-from-env'); + }); + + it('should fall back to config when env var is not set', () => { + delete process.env['TAKT_OPENAI_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'openai_api_key: sk-openai-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpenaiApiKey(); + expect(key).toBe('sk-openai-from-yaml'); + }); + + it('should return undefined when neither env var nor config is set', () => { + delete process.env['TAKT_OPENAI_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpenaiApiKey(); + expect(key).toBeUndefined(); + }); +}); + +describe('resolveCodexCliPath', () => { + const originalEnv = process.env['TAKT_CODEX_CLI_PATH']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_CODEX_CLI_PATH'] = originalEnv; + } else { + delete process.env['TAKT_CODEX_CLI_PATH']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var path when set', () => { + const envCodexPath = createExecutableFile('env-codex'); + const configCodexPath = createExecutableFile('config-codex'); + process.env['TAKT_CODEX_CLI_PATH'] = envCodexPath; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: codex', + `codex_cli_path: ${configCodexPath}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveCodexCliPath(); + expect(path).toBe(envCodexPath); + }); + + it('should fall back to config path when env var is not set', () => { + delete process.env['TAKT_CODEX_CLI_PATH']; + const configCodexPath = createExecutableFile('config-codex'); + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: codex', + `codex_cli_path: ${configCodexPath}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveCodexCliPath(); + expect(path).toBe(configCodexPath); + }); + + it('should return undefined when neither env var nor config is set', () => { + delete process.env['TAKT_CODEX_CLI_PATH']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: codex', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const path = resolveCodexCliPath(); + expect(path).toBeUndefined(); + }); + + it('should throw when env path is empty', () => { + process.env['TAKT_CODEX_CLI_PATH'] = ''; + expect(() => resolveCodexCliPath()).toThrow(/must not be empty/i); + }); + + it('should throw when env path does not exist', () => { + process.env['TAKT_CODEX_CLI_PATH'] = join(testDir, 'missing-codex'); + expect(() => resolveCodexCliPath()).toThrow(/does not exist/i); + }); + + it('should throw when env path points to a directory', () => { + const dirPath = join(testDir, 'codex-dir'); + mkdirSync(dirPath, { recursive: true }); + process.env['TAKT_CODEX_CLI_PATH'] = dirPath; + expect(() => resolveCodexCliPath()).toThrow(/executable file/i); + }); + + it('should throw when env path points to a non-executable file', () => { + process.env['TAKT_CODEX_CLI_PATH'] = createNonExecutableFile('non-executable-codex'); + expect(() => resolveCodexCliPath()).toThrow(/not executable/i); + }); + + it('should throw when env path is relative', () => { + process.env['TAKT_CODEX_CLI_PATH'] = 'bin/codex'; + expect(() => resolveCodexCliPath()).toThrow(/absolute path/i); + }); + + it('should throw when env path contains control characters', () => { + process.env['TAKT_CODEX_CLI_PATH'] = '/tmp/codex\nbad'; + expect(() => resolveCodexCliPath()).toThrow(/control characters/i); + }); + + it('should throw when config path is invalid', () => { + delete process.env['TAKT_CODEX_CLI_PATH']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: codex', + `codex_cli_path: ${join(testDir, 'missing-codex-from-config')}`, + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + expect(() => resolveCodexCliPath()).toThrow(/does not exist/i); + }); +}); + +describe('resolveOpencodeApiKey', () => { + const originalEnv = process.env['TAKT_OPENCODE_API_KEY']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_OPENCODE_API_KEY'] = originalEnv; + } else { + delete process.env['TAKT_OPENCODE_API_KEY']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var when set', () => { + process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env'; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'opencode_api_key: sk-opencode-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBe('sk-opencode-from-env'); + }); + + it('should fall back to config when env var is not set', () => { + delete process.env['TAKT_OPENCODE_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'opencode_api_key: sk-opencode-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBe('sk-opencode-from-yaml'); + }); + + it('should return undefined when neither env var nor config is set', () => { + delete process.env['TAKT_OPENCODE_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBeUndefined(); + }); +}); diff --git a/src/__tests__/provider-structured-output.test.ts b/src/__tests__/provider-structured-output.test.ts index 3f2206e..1532f23 100644 --- a/src/__tests__/provider-structured-output.test.ts +++ b/src/__tests__/provider-structured-output.test.ts @@ -56,6 +56,7 @@ vi.mock('../infra/opencode/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({ resolveAnthropicApiKey: vi.fn(() => undefined), resolveOpenaiApiKey: vi.fn(() => undefined), + resolveCodexCliPath: vi.fn(() => '/opt/codex/bin/codex'), resolveOpencodeApiKey: vi.fn(() => undefined), })); @@ -148,6 +149,7 @@ describe('CodexProvider — structured output', () => { const opts = mockCallCodex.mock.calls[0]?.[2]; expect(opts).toHaveProperty('outputSchema', SCHEMA); + expect(opts).toHaveProperty('codexPathOverride', '/opt/codex/bin/codex'); expect(result.structuredOutput).toEqual({ step: 2 }); }); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index c3c830a..f190e65 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -77,6 +77,8 @@ export interface GlobalConfig { anthropicApiKey?: string; /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ openaiApiKey?: string; + /** External Codex CLI path for Codex SDK override (overridden by TAKT_CODEX_CLI_PATH env var) */ + codexCliPath?: string; /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ opencodeApiKey?: string; /** Pipeline execution settings */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index db26d8f..36a7f73 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -429,6 +429,8 @@ export const GlobalConfigSchema = z.object({ anthropic_api_key: z.string().optional(), /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ openai_api_key: z.string().optional(), + /** External Codex CLI path for Codex SDK override (overridden by TAKT_CODEX_CLI_PATH env var) */ + codex_cli_path: z.string().optional(), /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ opencode_api_key: z.string().optional(), /** Pipeline execution settings */ diff --git a/src/infra/codex/client.ts b/src/infra/codex/client.ts index ceb49c5..cea67d0 100644 --- a/src/infra/codex/client.ts +++ b/src/infra/codex/client.ts @@ -104,7 +104,11 @@ export class CodexClient { : prompt; for (let attempt = 1; attempt <= CODEX_RETRY_MAX_ATTEMPTS; attempt++) { - const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined); + const codexClientOptions = { + ...(options.openaiApiKey ? { apiKey: options.openaiApiKey } : {}), + ...(options.codexPathOverride ? { codexPathOverride: options.codexPathOverride } : {}), + }; + const codex = new Codex(Object.keys(codexClientOptions).length > 0 ? codexClientOptions : undefined); const thread = threadId ? await codex.resumeThread(threadId, threadOptions) : await codex.startThread(threadOptions); diff --git a/src/infra/codex/types.ts b/src/infra/codex/types.ts index 102c79e..c371573 100644 --- a/src/infra/codex/types.ts +++ b/src/infra/codex/types.ts @@ -33,6 +33,8 @@ export interface CodexCallOptions { onStream?: StreamCallback; /** OpenAI API key (bypasses CLI auth) */ openaiApiKey?: string; + /** Override path to external Codex CLI binary (bypasses SDK vendored binary) */ + codexPathOverride?: string; /** JSON Schema for structured output */ outputSchema?: Record; } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index b51d140..13f4ba4 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -5,7 +5,8 @@ * GlobalConfigManager encapsulates the config cache as a singleton. */ -import { readFileSync, existsSync, writeFileSync } from 'node:fs'; +import { readFileSync, existsSync, writeFileSync, statSync, accessSync, constants } from 'node:fs'; +import { isAbsolute } from 'node:path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { GlobalConfigSchema } from '../../../core/models/index.js'; import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; @@ -18,6 +19,42 @@ import { parseProviderModel } from '../../../shared/utils/providerModel.js'; /** Claude-specific model aliases that are not valid for other providers */ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); +function hasControlCharacters(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 32 || code === 127) { + return true; + } + } + return false; +} + +function validateCodexCliPath(pathValue: string, sourceName: 'TAKT_CODEX_CLI_PATH' | 'codex_cli_path'): string { + const trimmed = pathValue.trim(); + if (trimmed.length === 0) { + throw new Error(`Configuration error: ${sourceName} must not be empty.`); + } + if (hasControlCharacters(trimmed)) { + throw new Error(`Configuration error: ${sourceName} contains control characters.`); + } + if (!isAbsolute(trimmed)) { + throw new Error(`Configuration error: ${sourceName} must be an absolute path: ${trimmed}`); + } + if (!existsSync(trimmed)) { + throw new Error(`Configuration error: ${sourceName} path does not exist: ${trimmed}`); + } + const stats = statSync(trimmed); + if (!stats.isFile()) { + throw new Error(`Configuration error: ${sourceName} must point to an executable file: ${trimmed}`); + } + try { + accessSync(trimmed, constants.X_OK); + } catch { + throw new Error(`Configuration error: ${sourceName} file is not executable: ${trimmed}`); + } + return trimmed; +} + /** Validate that provider and model are compatible */ function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void { if (!provider) return; @@ -144,6 +181,7 @@ export class GlobalConfigManager { enableBuiltinPieces: parsed.enable_builtin_pieces, anthropicApiKey: parsed.anthropic_api_key, openaiApiKey: parsed.openai_api_key, + codexCliPath: parsed.codex_cli_path, opencodeApiKey: parsed.opencode_api_key, pipeline: parsed.pipeline ? { defaultBranchPrefix: parsed.pipeline.default_branch_prefix, @@ -219,6 +257,9 @@ export class GlobalConfigManager { if (config.openaiApiKey) { raw.openai_api_key = config.openaiApiKey; } + if (config.codexCliPath) { + raw.codex_cli_path = config.codexCliPath; + } if (config.opencodeApiKey) { raw.opencode_api_key = config.opencodeApiKey; } @@ -379,6 +420,28 @@ export function resolveOpenaiApiKey(): string | undefined { } } +/** + * Resolve the Codex CLI path override. + * Priority: TAKT_CODEX_CLI_PATH env var > config.yaml > undefined (SDK vendored binary fallback) + */ +export function resolveCodexCliPath(): string | undefined { + const envPath = process.env['TAKT_CODEX_CLI_PATH']; + if (envPath !== undefined) { + return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); + } + + let config: GlobalConfig; + try { + config = loadGlobalConfig(); + } catch { + return undefined; + } + if (config.codexCliPath === undefined) { + return undefined; + } + return validateCodexCliPath(config.codexCliPath, 'codex_cli_path'); +} + /** * Resolve the OpenCode API key. * Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index 7f442b0..d501195 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -14,6 +14,7 @@ export { setProvider, resolveAnthropicApiKey, resolveOpenaiApiKey, + resolveCodexCliPath, resolveOpencodeApiKey, loadProjectDebugConfig, getEffectiveDebugConfig, diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.ts index c124f94..a32c6b9 100644 --- a/src/infra/providers/codex.ts +++ b/src/infra/providers/codex.ts @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process'; import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js'; -import { resolveOpenaiApiKey } from '../config/index.js'; +import { resolveOpenaiApiKey, resolveCodexCliPath } from '../config/index.js'; import type { AgentResponse } from '../../core/models/index.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; @@ -34,6 +34,7 @@ function toCodexOptions(options: ProviderCallOptions): CodexCallOptions { networkAccess: options.providerOptions?.codex?.networkAccess, onStream: options.onStream, openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), + codexPathOverride: resolveCodexCliPath(), outputSchema: options.outputSchema, }; }