Merge pull request #293 from nrslib/release/v0.18.2

Release v0.18.2
This commit is contained in:
nrs 2026-02-18 11:41:00 +09:00 committed by GitHub
commit b0594c30e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 600 additions and 350 deletions

View File

@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.18.2] - 2026-02-18
### Added
- Added `codex_cli_path` global config option and `TAKT_CODEX_CLI_PATH` environment variable to override the Codex CLI binary path used by the Codex SDK (#292)
- Supports strict validation: absolute path, file existence, executable permission, no control characters
- Priority: `TAKT_CODEX_CLI_PATH` env var > `codex_cli_path` in config.yaml > SDK vendored binary
## [0.18.1] - 2026-02-18 ## [0.18.1] - 2026-02-18
### Added ### Added

View File

@ -612,6 +612,11 @@ anthropic_api_key: sk-ant-... # For Claude (Anthropic)
# openai_api_key: sk-... # For Codex (OpenAI) # openai_api_key: sk-... # For Codex (OpenAI)
# opencode_api_key: ... # For OpenCode # opencode_api_key: ... # For OpenCode
# Codex CLI path override (optional)
# Override the Codex CLI binary used by the Codex SDK (must be an absolute path to an executable file)
# Can be overridden by TAKT_CODEX_CLI_PATH environment variable
# codex_cli_path: /usr/local/bin/codex
# Builtin piece filtering (optional) # Builtin piece filtering (optional)
# builtin_pieces_enabled: true # Set false to disable all builtins # builtin_pieces_enabled: true # Set false to disable all builtins
# disabled_builtins: [magi, passthrough] # Disable specific builtin pieces # disabled_builtins: [magi, passthrough] # Disable specific builtin pieces

View File

@ -6,6 +6,14 @@
フォーマットは [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) に基づいています。 フォーマットは [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) に基づいています。
## [0.18.2] - 2026-02-18
### Added
- グローバル設定に `codex_cli_path` オプションと `TAKT_CODEX_CLI_PATH` 環境変数を追加 — Codex SDK が使用する CLI バイナリのパスを上書き可能に (#292)
- 厳密なバリデーション付き: 絶対パス、ファイル存在確認、実行権限、制御文字の禁止
- 優先順位: `TAKT_CODEX_CLI_PATH` 環境変数 > config.yaml の `codex_cli_path` > SDK 同梱バイナリ
## [0.18.1] - 2026-02-18 ## [0.18.1] - 2026-02-18
### Added ### Added

View File

@ -612,6 +612,11 @@ anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合
# openai_api_key: sk-... # Codex (OpenAI) を使う場合 # openai_api_key: sk-... # Codex (OpenAI) を使う場合
# opencode_api_key: ... # OpenCode を使う場合 # opencode_api_key: ... # OpenCode を使う場合
# Codex CLI パスの上書き(オプション)
# Codex SDK が使用する CLI バイナリを上書き(実行可能ファイルの絶対パスを指定)
# 環境変数 TAKT_CODEX_CLI_PATH で上書き可能
# codex_cli_path: /usr/local/bin/codex
# ビルトインピースのフィルタリング(オプション) # ビルトインピースのフィルタリング(オプション)
# builtin_pieces_enabled: true # false でビルトイン全体を無効化 # builtin_pieces_enabled: true # false でビルトイン全体を無効化
# disabled_builtins: [magi, passthrough] # 特定のビルトインピースを無効化 # disabled_builtins: [magi, passthrough] # 特定のビルトインピースを無効化

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "takt", "name": "takt",
"version": "0.18.1", "version": "0.18.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "takt", "name": "takt",
"version": "0.18.1", "version": "0.18.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37", "@anthropic-ai/claude-agent-sdk": "^0.2.37",

View File

@ -1,6 +1,6 @@
{ {
"name": "takt", "name": "takt",
"version": "0.18.1", "version": "0.18.2",
"description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration", "description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -15,10 +15,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
let mockEvents: Array<Record<string, unknown>> = []; let mockEvents: Array<Record<string, unknown>> = [];
let lastThreadOptions: Record<string, unknown> | undefined; let lastThreadOptions: Record<string, unknown> | undefined;
let lastCodexConstructorOptions: Record<string, unknown> | undefined;
vi.mock('@openai/codex-sdk', () => { vi.mock('@openai/codex-sdk', () => {
return { return {
Codex: class MockCodex { Codex: class MockCodex {
constructor(options?: Record<string, unknown>) {
lastCodexConstructorOptions = options;
}
async startThread(options?: Record<string, unknown>) { async startThread(options?: Record<string, unknown>) {
lastThreadOptions = options; lastThreadOptions = options;
return { return {
@ -47,6 +51,7 @@ describe('CodexClient — structuredOutput 抽出', () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockEvents = []; mockEvents = [];
lastThreadOptions = undefined; lastThreadOptions = undefined;
lastCodexConstructorOptions = undefined;
}); });
it('outputSchema 指定時に agent_message の JSON テキストを structuredOutput として返す', async () => { it('outputSchema 指定時に agent_message の JSON テキストを structuredOutput として返す', async () => {
@ -169,4 +174,21 @@ describe('CodexClient — structuredOutput 抽出', () => {
networkAccessEnabled: true, 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',
});
});
}); });

View File

@ -1,344 +1,469 @@
/** /**
* Tests for API key authentication feature * Tests for API key authentication feature
* *
* Tests the resolution logic for Anthropic and OpenAI API keys: * Tests the resolution logic for Anthropic and OpenAI API keys:
* - Environment variable priority over config.yaml * - Environment variable priority over config.yaml
* - Config.yaml fallback when env var is not set * - Config.yaml fallback when env var is not set
* - Undefined when neither is set * - Undefined when neither is set
* - Schema validation for API key fields * - Schema validation for API key fields
* - GlobalConfig load/save round-trip with API keys * - GlobalConfig load/save round-trip with API keys
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; import { mkdirSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { GlobalConfigSchema } from '../core/models/index.js'; import { GlobalConfigSchema } from '../core/models/index.js';
// Mock paths module to redirect config to temp directory // Mock paths module to redirect config to temp directory
const testId = randomUUID(); const testId = randomUUID();
const testDir = join(tmpdir(), `takt-api-key-test-${testId}`); const testDir = join(tmpdir(), `takt-api-key-test-${testId}`);
const taktDir = join(testDir, '.takt'); const taktDir = join(testDir, '.takt');
const configPath = join(taktDir, 'config.yaml'); const configPath = join(taktDir, 'config.yaml');
vi.mock('../infra/config/paths.js', async (importOriginal) => { function createExecutableFile(filename: string): string {
const original = await importOriginal() as Record<string, unknown>; const filePath = join(testDir, filename);
return { writeFileSync(filePath, '#!/bin/sh\necho codex\n', 'utf-8');
...original, chmodSync(filePath, 0o755);
getGlobalConfigPath: () => configPath, return filePath;
getTaktDir: () => taktDir, }
};
}); function createNonExecutableFile(filename: string): string {
const filePath = join(testDir, filename);
// Import after mocking writeFileSync(filePath, '#!/bin/sh\necho codex\n', 'utf-8');
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); chmodSync(filePath, 0o644);
return filePath;
describe('GlobalConfigSchema API key fields', () => { }
it('should accept config without API keys', () => {
const result = GlobalConfigSchema.parse({ vi.mock('../infra/config/paths.js', async (importOriginal) => {
language: 'en', const original = await importOriginal() as Record<string, unknown>;
}); return {
expect(result.anthropic_api_key).toBeUndefined(); ...original,
expect(result.openai_api_key).toBeUndefined(); getGlobalConfigPath: () => configPath,
}); getTaktDir: () => taktDir,
};
it('should accept config with anthropic_api_key', () => { });
const result = GlobalConfigSchema.parse({
language: 'en', // Import after mocking
anthropic_api_key: 'sk-ant-test-key', const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveCodexCliPath, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
});
expect(result.anthropic_api_key).toBe('sk-ant-test-key'); describe('GlobalConfigSchema API key fields', () => {
}); it('should accept config without API keys', () => {
const result = GlobalConfigSchema.parse({
it('should accept config with openai_api_key', () => { language: 'en',
const result = GlobalConfigSchema.parse({ });
language: 'en', expect(result.anthropic_api_key).toBeUndefined();
openai_api_key: 'sk-openai-test-key', expect(result.openai_api_key).toBeUndefined();
}); });
expect(result.openai_api_key).toBe('sk-openai-test-key');
}); it('should accept config with anthropic_api_key', () => {
const result = GlobalConfigSchema.parse({
it('should accept config with both API keys', () => { language: 'en',
const result = GlobalConfigSchema.parse({ anthropic_api_key: 'sk-ant-test-key',
language: 'en', });
anthropic_api_key: 'sk-ant-key', expect(result.anthropic_api_key).toBe('sk-ant-test-key');
openai_api_key: 'sk-openai-key', });
});
expect(result.anthropic_api_key).toBe('sk-ant-key'); it('should accept config with openai_api_key', () => {
expect(result.openai_api_key).toBe('sk-openai-key'); const result = GlobalConfigSchema.parse({
}); language: 'en',
}); openai_api_key: 'sk-openai-test-key',
});
describe('GlobalConfig load/save with API keys', () => { expect(result.openai_api_key).toBe('sk-openai-test-key');
beforeEach(() => { });
invalidateGlobalConfigCache();
mkdirSync(taktDir, { recursive: true }); it('should accept config with both API keys', () => {
}); const result = GlobalConfigSchema.parse({
language: 'en',
afterEach(() => { anthropic_api_key: 'sk-ant-key',
rmSync(testDir, { recursive: true, force: true }); openai_api_key: 'sk-openai-key',
}); });
expect(result.anthropic_api_key).toBe('sk-ant-key');
it('should load config with API keys from YAML', () => { expect(result.openai_api_key).toBe('sk-openai-key');
const yaml = [ });
'language: en', });
'default_piece: default',
'log_level: info', describe('GlobalConfig load/save with API keys', () => {
'provider: claude', beforeEach(() => {
'anthropic_api_key: sk-ant-from-yaml', invalidateGlobalConfigCache();
'openai_api_key: sk-openai-from-yaml', mkdirSync(taktDir, { recursive: true });
].join('\n'); });
writeFileSync(configPath, yaml, 'utf-8');
afterEach(() => {
const config = loadGlobalConfig(); rmSync(testDir, { recursive: true, force: true });
expect(config.anthropicApiKey).toBe('sk-ant-from-yaml'); });
expect(config.openaiApiKey).toBe('sk-openai-from-yaml');
}); it('should load config with API keys from YAML', () => {
const yaml = [
it('should load config without API keys', () => { 'language: en',
const yaml = [ 'default_piece: default',
'language: en', 'log_level: info',
'default_piece: default', 'provider: claude',
'log_level: info', 'anthropic_api_key: sk-ant-from-yaml',
'provider: claude', 'openai_api_key: sk-openai-from-yaml',
].join('\n'); ].join('\n');
writeFileSync(configPath, yaml, 'utf-8'); writeFileSync(configPath, yaml, 'utf-8');
const config = loadGlobalConfig(); const config = loadGlobalConfig();
expect(config.anthropicApiKey).toBeUndefined(); expect(config.anthropicApiKey).toBe('sk-ant-from-yaml');
expect(config.openaiApiKey).toBeUndefined(); expect(config.openaiApiKey).toBe('sk-openai-from-yaml');
}); });
it('should save and reload config with API keys', () => { it('should load config without API keys', () => {
// Write initial config const yaml = [
const yaml = [ 'language: en',
'language: en', 'default_piece: default',
'default_piece: default', 'log_level: info',
'log_level: info', 'provider: claude',
'provider: claude', ].join('\n');
].join('\n'); writeFileSync(configPath, yaml, 'utf-8');
writeFileSync(configPath, yaml, 'utf-8');
const config = loadGlobalConfig();
const config = loadGlobalConfig(); expect(config.anthropicApiKey).toBeUndefined();
config.anthropicApiKey = 'sk-ant-saved'; expect(config.openaiApiKey).toBeUndefined();
config.openaiApiKey = 'sk-openai-saved'; });
saveGlobalConfig(config);
it('should save and reload config with API keys', () => {
const reloaded = loadGlobalConfig(); // Write initial config
expect(reloaded.anthropicApiKey).toBe('sk-ant-saved'); const yaml = [
expect(reloaded.openaiApiKey).toBe('sk-openai-saved'); 'language: en',
}); 'default_piece: default',
'log_level: info',
it('should not persist API keys when not set', () => { 'provider: claude',
const yaml = [ ].join('\n');
'language: en', writeFileSync(configPath, yaml, 'utf-8');
'default_piece: default',
'log_level: info', const config = loadGlobalConfig();
'provider: claude', config.anthropicApiKey = 'sk-ant-saved';
].join('\n'); config.openaiApiKey = 'sk-openai-saved';
writeFileSync(configPath, yaml, 'utf-8'); saveGlobalConfig(config);
const config = loadGlobalConfig(); const reloaded = loadGlobalConfig();
saveGlobalConfig(config); expect(reloaded.anthropicApiKey).toBe('sk-ant-saved');
expect(reloaded.openaiApiKey).toBe('sk-openai-saved');
const content = readFileSync(configPath, 'utf-8'); });
expect(content).not.toContain('anthropic_api_key');
expect(content).not.toContain('openai_api_key'); it('should not persist API keys when not set', () => {
}); const yaml = [
}); 'language: en',
'default_piece: default',
describe('resolveAnthropicApiKey', () => { 'log_level: info',
const originalEnv = process.env['TAKT_ANTHROPIC_API_KEY']; 'provider: claude',
].join('\n');
beforeEach(() => { writeFileSync(configPath, yaml, 'utf-8');
invalidateGlobalConfigCache();
mkdirSync(taktDir, { recursive: true }); const config = loadGlobalConfig();
}); saveGlobalConfig(config);
afterEach(() => { const content = readFileSync(configPath, 'utf-8');
if (originalEnv !== undefined) { expect(content).not.toContain('anthropic_api_key');
process.env['TAKT_ANTHROPIC_API_KEY'] = originalEnv; expect(content).not.toContain('openai_api_key');
} else { });
delete process.env['TAKT_ANTHROPIC_API_KEY']; });
}
rmSync(testDir, { recursive: true, force: true }); describe('resolveAnthropicApiKey', () => {
}); const originalEnv = process.env['TAKT_ANTHROPIC_API_KEY'];
it('should return env var when set', () => { beforeEach(() => {
process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env'; invalidateGlobalConfigCache();
const yaml = [ mkdirSync(taktDir, { recursive: true });
'language: en', });
'default_piece: default',
'log_level: info', afterEach(() => {
'provider: claude', if (originalEnv !== undefined) {
'anthropic_api_key: sk-ant-from-yaml', process.env['TAKT_ANTHROPIC_API_KEY'] = originalEnv;
].join('\n'); } else {
writeFileSync(configPath, yaml, 'utf-8'); delete process.env['TAKT_ANTHROPIC_API_KEY'];
}
const key = resolveAnthropicApiKey(); rmSync(testDir, { recursive: true, force: true });
expect(key).toBe('sk-ant-from-env'); });
});
it('should return env var when set', () => {
it('should fall back to config when env var is not set', () => { process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env';
delete process.env['TAKT_ANTHROPIC_API_KEY']; const yaml = [
const yaml = [ 'language: en',
'language: en', 'default_piece: default',
'default_piece: default', 'log_level: info',
'log_level: info', 'provider: claude',
'provider: claude', 'anthropic_api_key: sk-ant-from-yaml',
'anthropic_api_key: sk-ant-from-yaml', ].join('\n');
].join('\n'); writeFileSync(configPath, yaml, 'utf-8');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveAnthropicApiKey();
const key = resolveAnthropicApiKey(); expect(key).toBe('sk-ant-from-env');
expect(key).toBe('sk-ant-from-yaml'); });
});
it('should fall back to config when env var is not set', () => {
it('should return undefined when neither env var nor config is set', () => { delete process.env['TAKT_ANTHROPIC_API_KEY'];
delete process.env['TAKT_ANTHROPIC_API_KEY']; const yaml = [
const yaml = [ 'language: en',
'language: en', 'default_piece: default',
'default_piece: default', 'log_level: info',
'log_level: info', 'provider: claude',
'provider: claude', 'anthropic_api_key: sk-ant-from-yaml',
].join('\n'); ].join('\n');
writeFileSync(configPath, yaml, 'utf-8'); writeFileSync(configPath, yaml, 'utf-8');
const key = resolveAnthropicApiKey(); const key = resolveAnthropicApiKey();
expect(key).toBeUndefined(); expect(key).toBe('sk-ant-from-yaml');
}); });
it('should return undefined when config file does not exist', () => { it('should return undefined when neither env var nor config is set', () => {
delete process.env['TAKT_ANTHROPIC_API_KEY']; delete process.env['TAKT_ANTHROPIC_API_KEY'];
// No config file created const yaml = [
rmSync(testDir, { recursive: true, force: true }); 'language: en',
'default_piece: default',
const key = resolveAnthropicApiKey(); 'log_level: info',
expect(key).toBeUndefined(); 'provider: claude',
}); ].join('\n');
}); writeFileSync(configPath, yaml, 'utf-8');
describe('resolveOpenaiApiKey', () => { const key = resolveAnthropicApiKey();
const originalEnv = process.env['TAKT_OPENAI_API_KEY']; expect(key).toBeUndefined();
});
beforeEach(() => {
invalidateGlobalConfigCache(); it('should return undefined when config file does not exist', () => {
mkdirSync(taktDir, { recursive: true }); delete process.env['TAKT_ANTHROPIC_API_KEY'];
}); // No config file created
rmSync(testDir, { recursive: true, force: true });
afterEach(() => {
if (originalEnv !== undefined) { const key = resolveAnthropicApiKey();
process.env['TAKT_OPENAI_API_KEY'] = originalEnv; expect(key).toBeUndefined();
} else { });
delete process.env['TAKT_OPENAI_API_KEY']; });
}
rmSync(testDir, { recursive: true, force: true }); describe('resolveOpenaiApiKey', () => {
}); const originalEnv = process.env['TAKT_OPENAI_API_KEY'];
it('should return env var when set', () => { beforeEach(() => {
process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env'; invalidateGlobalConfigCache();
const yaml = [ mkdirSync(taktDir, { recursive: true });
'language: en', });
'default_piece: default',
'log_level: info', afterEach(() => {
'provider: claude', if (originalEnv !== undefined) {
'openai_api_key: sk-openai-from-yaml', process.env['TAKT_OPENAI_API_KEY'] = originalEnv;
].join('\n'); } else {
writeFileSync(configPath, yaml, 'utf-8'); delete process.env['TAKT_OPENAI_API_KEY'];
}
const key = resolveOpenaiApiKey(); rmSync(testDir, { recursive: true, force: true });
expect(key).toBe('sk-openai-from-env'); });
});
it('should return env var when set', () => {
it('should fall back to config when env var is not set', () => { process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env';
delete process.env['TAKT_OPENAI_API_KEY']; const yaml = [
const yaml = [ 'language: en',
'language: en', 'default_piece: default',
'default_piece: default', 'log_level: info',
'log_level: info', 'provider: claude',
'provider: claude', 'openai_api_key: sk-openai-from-yaml',
'openai_api_key: sk-openai-from-yaml', ].join('\n');
].join('\n'); writeFileSync(configPath, yaml, 'utf-8');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveOpenaiApiKey();
const key = resolveOpenaiApiKey(); expect(key).toBe('sk-openai-from-env');
expect(key).toBe('sk-openai-from-yaml'); });
});
it('should fall back to config when env var is not set', () => {
it('should return undefined when neither env var nor config is set', () => { delete process.env['TAKT_OPENAI_API_KEY'];
delete process.env['TAKT_OPENAI_API_KEY']; const yaml = [
const yaml = [ 'language: en',
'language: en', 'default_piece: default',
'default_piece: default', 'log_level: info',
'log_level: info', 'provider: claude',
'provider: claude', 'openai_api_key: sk-openai-from-yaml',
].join('\n'); ].join('\n');
writeFileSync(configPath, yaml, 'utf-8'); writeFileSync(configPath, yaml, 'utf-8');
const key = resolveOpenaiApiKey(); const key = resolveOpenaiApiKey();
expect(key).toBeUndefined(); expect(key).toBe('sk-openai-from-yaml');
}); });
});
it('should return undefined when neither env var nor config is set', () => {
describe('resolveOpencodeApiKey', () => { delete process.env['TAKT_OPENAI_API_KEY'];
const originalEnv = process.env['TAKT_OPENCODE_API_KEY']; const yaml = [
'language: en',
beforeEach(() => { 'default_piece: default',
invalidateGlobalConfigCache(); 'log_level: info',
mkdirSync(taktDir, { recursive: true }); 'provider: claude',
}); ].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
afterEach(() => {
if (originalEnv !== undefined) { const key = resolveOpenaiApiKey();
process.env['TAKT_OPENCODE_API_KEY'] = originalEnv; expect(key).toBeUndefined();
} else { });
delete process.env['TAKT_OPENCODE_API_KEY']; });
}
rmSync(testDir, { recursive: true, force: true }); describe('resolveCodexCliPath', () => {
}); const originalEnv = process.env['TAKT_CODEX_CLI_PATH'];
it('should return env var when set', () => { beforeEach(() => {
process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env'; invalidateGlobalConfigCache();
const yaml = [ mkdirSync(taktDir, { recursive: true });
'language: en', });
'default_piece: default',
'log_level: info', afterEach(() => {
'provider: claude', if (originalEnv !== undefined) {
'opencode_api_key: sk-opencode-from-yaml', process.env['TAKT_CODEX_CLI_PATH'] = originalEnv;
].join('\n'); } else {
writeFileSync(configPath, yaml, 'utf-8'); delete process.env['TAKT_CODEX_CLI_PATH'];
}
const key = resolveOpencodeApiKey(); rmSync(testDir, { recursive: true, force: true });
expect(key).toBe('sk-opencode-from-env'); });
});
it('should return env var path when set', () => {
it('should fall back to config when env var is not set', () => { const envCodexPath = createExecutableFile('env-codex');
delete process.env['TAKT_OPENCODE_API_KEY']; const configCodexPath = createExecutableFile('config-codex');
const yaml = [ process.env['TAKT_CODEX_CLI_PATH'] = envCodexPath;
'language: en', const yaml = [
'default_piece: default', 'language: en',
'log_level: info', 'default_piece: default',
'provider: claude', 'log_level: info',
'opencode_api_key: sk-opencode-from-yaml', 'provider: codex',
].join('\n'); `codex_cli_path: ${configCodexPath}`,
writeFileSync(configPath, yaml, 'utf-8'); ].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveOpencodeApiKey();
expect(key).toBe('sk-opencode-from-yaml'); const path = resolveCodexCliPath();
}); expect(path).toBe(envCodexPath);
});
it('should return undefined when neither env var nor config is set', () => {
delete process.env['TAKT_OPENCODE_API_KEY']; it('should fall back to config path when env var is not set', () => {
const yaml = [ delete process.env['TAKT_CODEX_CLI_PATH'];
'language: en', const configCodexPath = createExecutableFile('config-codex');
'default_piece: default', const yaml = [
'log_level: info', 'language: en',
'provider: claude', 'default_piece: default',
].join('\n'); 'log_level: info',
writeFileSync(configPath, yaml, 'utf-8'); 'provider: codex',
`codex_cli_path: ${configCodexPath}`,
const key = resolveOpencodeApiKey(); ].join('\n');
expect(key).toBeUndefined(); 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();
});
});

View File

@ -56,6 +56,7 @@ vi.mock('../infra/opencode/index.js', () => ({
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
resolveAnthropicApiKey: vi.fn(() => undefined), resolveAnthropicApiKey: vi.fn(() => undefined),
resolveOpenaiApiKey: vi.fn(() => undefined), resolveOpenaiApiKey: vi.fn(() => undefined),
resolveCodexCliPath: vi.fn(() => '/opt/codex/bin/codex'),
resolveOpencodeApiKey: vi.fn(() => undefined), resolveOpencodeApiKey: vi.fn(() => undefined),
})); }));
@ -148,6 +149,7 @@ describe('CodexProvider — structured output', () => {
const opts = mockCallCodex.mock.calls[0]?.[2]; const opts = mockCallCodex.mock.calls[0]?.[2];
expect(opts).toHaveProperty('outputSchema', SCHEMA); expect(opts).toHaveProperty('outputSchema', SCHEMA);
expect(opts).toHaveProperty('codexPathOverride', '/opt/codex/bin/codex');
expect(result.structuredOutput).toEqual({ step: 2 }); expect(result.structuredOutput).toEqual({ step: 2 });
}); });

View File

@ -77,6 +77,8 @@ export interface GlobalConfig {
anthropicApiKey?: string; anthropicApiKey?: string;
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openaiApiKey?: string; 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) */ /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencodeApiKey?: string; opencodeApiKey?: string;
/** Pipeline execution settings */ /** Pipeline execution settings */

View File

@ -429,6 +429,8 @@ export const GlobalConfigSchema = z.object({
anthropic_api_key: z.string().optional(), anthropic_api_key: z.string().optional(),
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openai_api_key: z.string().optional(), 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 for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencode_api_key: z.string().optional(), opencode_api_key: z.string().optional(),
/** Pipeline execution settings */ /** Pipeline execution settings */

View File

@ -104,7 +104,11 @@ export class CodexClient {
: prompt; : prompt;
for (let attempt = 1; attempt <= CODEX_RETRY_MAX_ATTEMPTS; attempt++) { 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 const thread = threadId
? await codex.resumeThread(threadId, threadOptions) ? await codex.resumeThread(threadId, threadOptions)
: await codex.startThread(threadOptions); : await codex.startThread(threadOptions);

View File

@ -33,6 +33,8 @@ export interface CodexCallOptions {
onStream?: StreamCallback; onStream?: StreamCallback;
/** OpenAI API key (bypasses CLI auth) */ /** OpenAI API key (bypasses CLI auth) */
openaiApiKey?: string; openaiApiKey?: string;
/** Override path to external Codex CLI binary (bypasses SDK vendored binary) */
codexPathOverride?: string;
/** JSON Schema for structured output */ /** JSON Schema for structured output */
outputSchema?: Record<string, unknown>; outputSchema?: Record<string, unknown>;
} }

View File

@ -5,7 +5,8 @@
* GlobalConfigManager encapsulates the config cache as a singleton. * 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 { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js'; import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { GlobalConfig, DebugConfig, Language } 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 */ /** Claude-specific model aliases that are not valid for other providers */
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); 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 */ /** Validate that provider and model are compatible */
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void { function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
if (!provider) return; if (!provider) return;
@ -144,6 +181,7 @@ export class GlobalConfigManager {
enableBuiltinPieces: parsed.enable_builtin_pieces, enableBuiltinPieces: parsed.enable_builtin_pieces,
anthropicApiKey: parsed.anthropic_api_key, anthropicApiKey: parsed.anthropic_api_key,
openaiApiKey: parsed.openai_api_key, openaiApiKey: parsed.openai_api_key,
codexCliPath: parsed.codex_cli_path,
opencodeApiKey: parsed.opencode_api_key, opencodeApiKey: parsed.opencode_api_key,
pipeline: parsed.pipeline ? { pipeline: parsed.pipeline ? {
defaultBranchPrefix: parsed.pipeline.default_branch_prefix, defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
@ -219,6 +257,9 @@ export class GlobalConfigManager {
if (config.openaiApiKey) { if (config.openaiApiKey) {
raw.openai_api_key = config.openaiApiKey; raw.openai_api_key = config.openaiApiKey;
} }
if (config.codexCliPath) {
raw.codex_cli_path = config.codexCliPath;
}
if (config.opencodeApiKey) { if (config.opencodeApiKey) {
raw.opencode_api_key = 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. * Resolve the OpenCode API key.
* Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined * Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined

View File

@ -14,6 +14,7 @@ export {
setProvider, setProvider,
resolveAnthropicApiKey, resolveAnthropicApiKey,
resolveOpenaiApiKey, resolveOpenaiApiKey,
resolveCodexCliPath,
resolveOpencodeApiKey, resolveOpencodeApiKey,
loadProjectDebugConfig, loadProjectDebugConfig,
getEffectiveDebugConfig, getEffectiveDebugConfig,

View File

@ -4,7 +4,7 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js'; 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 { AgentResponse } from '../../core/models/index.js';
import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
@ -34,6 +34,7 @@ function toCodexOptions(options: ProviderCallOptions): CodexCallOptions {
networkAccess: options.providerOptions?.codex?.networkAccess, networkAccess: options.providerOptions?.codex?.networkAccess,
onStream: options.onStream, onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
codexPathOverride: resolveCodexCliPath(),
outputSchema: options.outputSchema, outputSchema: options.outputSchema,
}; };
} }