mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
- Removed redundant definition of CLI base timeout in `cli-provider.ts` and added a detailed comment explaining its purpose. - Updated `codex-provider.ts` to use the imported `DEFAULT_TIMEOUT_MS` directly instead of an alias. - Enhanced unit tests to ensure fallback behavior for invalid reasoning effort values in timeout calculations.
1132 lines
36 KiB
TypeScript
1132 lines
36 KiB
TypeScript
/**
|
|
* Codex Provider - Executes queries using Codex CLI
|
|
*
|
|
* Spawns the Codex CLI and converts JSONL output into ProviderMessage format.
|
|
*/
|
|
|
|
import path from 'path';
|
|
import { BaseProvider } from './base-provider.js';
|
|
import {
|
|
spawnJSONLProcess,
|
|
spawnProcess,
|
|
findCodexCliPath,
|
|
getCodexAuthIndicators,
|
|
secureFs,
|
|
getDataDirectory,
|
|
getCodexConfigDir,
|
|
} from '@automaker/platform';
|
|
import { checkCodexAuthentication } from '../lib/codex-auth.js';
|
|
import {
|
|
formatHistoryAsText,
|
|
extractTextFromContent,
|
|
classifyError,
|
|
getUserFriendlyErrorMessage,
|
|
createLogger,
|
|
} from '@automaker/utils';
|
|
import type {
|
|
ExecuteOptions,
|
|
ProviderMessage,
|
|
InstallationStatus,
|
|
ModelDefinition,
|
|
} from './types.js';
|
|
import {
|
|
CODEX_MODEL_MAP,
|
|
supportsReasoningEffort,
|
|
validateBareModelId,
|
|
calculateReasoningTimeout,
|
|
DEFAULT_TIMEOUT_MS,
|
|
type CodexApprovalPolicy,
|
|
type CodexSandboxMode,
|
|
type CodexAuthStatus,
|
|
} from '@automaker/types';
|
|
import { CodexConfigManager } from './codex-config-manager.js';
|
|
import { executeCodexSdkQuery } from './codex-sdk-client.js';
|
|
import {
|
|
resolveCodexToolCall,
|
|
extractCodexTodoItems,
|
|
getCodexTodoToolName,
|
|
} from './codex-tool-mapping.js';
|
|
import { SettingsService } from '../services/settings-service.js';
|
|
import { createTempEnvOverride } from '../lib/auth-utils.js';
|
|
import { checkSandboxCompatibility } from '../lib/sdk-options.js';
|
|
import { CODEX_MODELS } from './codex-models.js';
|
|
|
|
const CODEX_COMMAND = 'codex';
|
|
const CODEX_EXEC_SUBCOMMAND = 'exec';
|
|
const CODEX_JSON_FLAG = '--json';
|
|
const CODEX_MODEL_FLAG = '--model';
|
|
const CODEX_VERSION_FLAG = '--version';
|
|
const CODEX_SANDBOX_FLAG = '--sandbox';
|
|
const CODEX_APPROVAL_FLAG = '--ask-for-approval';
|
|
const CODEX_SEARCH_FLAG = '--search';
|
|
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
|
|
const CODEX_CONFIG_FLAG = '--config';
|
|
const CODEX_IMAGE_FLAG = '--image';
|
|
const CODEX_ADD_DIR_FLAG = '--add-dir';
|
|
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
|
|
const CODEX_RESUME_FLAG = 'resume';
|
|
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
|
|
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
|
|
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
|
const CODEX_EXECUTION_MODE_CLI = 'cli';
|
|
const CODEX_EXECUTION_MODE_SDK = 'sdk';
|
|
const ERROR_CODEX_CLI_REQUIRED =
|
|
'Codex CLI is required for tool-enabled requests. Please install Codex CLI and run `codex login`.';
|
|
const ERROR_CODEX_AUTH_REQUIRED = "Codex authentication is required. Please run 'codex login'.";
|
|
const ERROR_CODEX_SDK_AUTH_REQUIRED = 'OpenAI API key required for Codex SDK execution.';
|
|
|
|
const CODEX_EVENT_TYPES = {
|
|
itemCompleted: 'item.completed',
|
|
itemStarted: 'item.started',
|
|
itemUpdated: 'item.updated',
|
|
turnCompleted: 'turn.completed',
|
|
error: 'error',
|
|
} as const;
|
|
|
|
const CODEX_ITEM_TYPES = {
|
|
reasoning: 'reasoning',
|
|
agentMessage: 'agent_message',
|
|
commandExecution: 'command_execution',
|
|
todoList: 'todo_list',
|
|
} as const;
|
|
|
|
const SYSTEM_PROMPT_LABEL = 'System instructions';
|
|
const HISTORY_HEADER = 'Current request:\n';
|
|
const TEXT_ENCODING = 'utf-8';
|
|
/**
|
|
* Default timeout for Codex CLI operations in milliseconds.
|
|
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
|
|
* for this duration, the process is killed. For reasoning models with high
|
|
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
|
|
* @see calculateReasoningTimeout from @automaker/types
|
|
*/
|
|
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
|
const CONTEXT_WINDOW_256K = 256000;
|
|
const MAX_OUTPUT_32K = 32000;
|
|
const MAX_OUTPUT_16K = 16000;
|
|
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
|
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
|
const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
|
|
const CODEX_INSTRUCTIONS_PATH_LABEL = 'Path';
|
|
const CODEX_INSTRUCTIONS_SOURCE_LABEL = 'Source';
|
|
const CODEX_INSTRUCTIONS_USER_SOURCE = 'User instructions';
|
|
const CODEX_INSTRUCTIONS_PROJECT_SOURCE = 'Project instructions';
|
|
const CODEX_USER_INSTRUCTIONS_FILE = 'AGENTS.md';
|
|
const CODEX_PROJECT_INSTRUCTIONS_FILES = ['AGENTS.md'] as const;
|
|
const CODEX_SETTINGS_DIR_FALLBACK = './data';
|
|
const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false;
|
|
const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write';
|
|
const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request';
|
|
const TOOL_USE_ID_PREFIX = 'codex-tool-';
|
|
const ITEM_ID_KEYS = ['id', 'item_id', 'call_id', 'tool_use_id', 'command_id'] as const;
|
|
const EVENT_ID_KEYS = ['id', 'event_id', 'request_id'] as const;
|
|
const COMMAND_OUTPUT_FIELDS = ['output', 'stdout', 'stderr', 'result'] as const;
|
|
const COMMAND_OUTPUT_SEPARATOR = '\n';
|
|
const OUTPUT_SCHEMA_FILENAME = 'output-schema.json';
|
|
const OUTPUT_SCHEMA_INDENT_SPACES = 2;
|
|
const IMAGE_TEMP_DIR = '.codex-images';
|
|
const IMAGE_FILE_PREFIX = 'image-';
|
|
const IMAGE_FILE_EXT = '.png';
|
|
const DEFAULT_ALLOWED_TOOLS = [
|
|
'Read',
|
|
'Write',
|
|
'Edit',
|
|
'Glob',
|
|
'Grep',
|
|
'Bash',
|
|
'WebSearch',
|
|
'WebFetch',
|
|
] as const;
|
|
const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
|
|
const MIN_MAX_TURNS = 1;
|
|
const CONFIG_KEY_MAX_TURNS = 'max_turns';
|
|
const CONSTRAINTS_SECTION_TITLE = 'Codex Execution Constraints';
|
|
const CONSTRAINTS_MAX_TURNS_LABEL = 'Max turns';
|
|
const CONSTRAINTS_ALLOWED_TOOLS_LABEL = 'Allowed tools';
|
|
const CONSTRAINTS_OUTPUT_SCHEMA_LABEL = 'Output format';
|
|
const CONSTRAINTS_SESSION_ID_LABEL = 'Session ID';
|
|
const CONSTRAINTS_NO_TOOLS_VALUE = 'none';
|
|
const CONSTRAINTS_OUTPUT_SCHEMA_VALUE = 'Respond with JSON that matches the provided schema.';
|
|
|
|
type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTION_MODE_SDK;
|
|
type CodexExecutionPlan = {
|
|
mode: CodexExecutionMode;
|
|
cliPath: string | null;
|
|
openAiApiKey?: string | null;
|
|
};
|
|
|
|
const ALLOWED_ENV_VARS = [
|
|
OPENAI_API_KEY_ENV,
|
|
'PATH',
|
|
'HOME',
|
|
'SHELL',
|
|
'TERM',
|
|
'USER',
|
|
'LANG',
|
|
'LC_ALL',
|
|
];
|
|
|
|
function buildEnv(): Record<string, string> {
|
|
const env: Record<string, string> = {};
|
|
for (const key of ALLOWED_ENV_VARS) {
|
|
const value = process.env[key];
|
|
if (value) {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
async function resolveOpenAiApiKey(): Promise<string | null> {
|
|
const envKey = process.env[OPENAI_API_KEY_ENV];
|
|
if (envKey) {
|
|
return envKey;
|
|
}
|
|
|
|
try {
|
|
const settingsService = new SettingsService(getCodexSettingsDir());
|
|
const credentials = await settingsService.getCredentials();
|
|
const storedKey = credentials.apiKeys.openai?.trim();
|
|
return storedKey ? storedKey : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function hasMcpServersConfigured(options: ExecuteOptions): boolean {
|
|
return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0);
|
|
}
|
|
|
|
function isNoToolsRequested(options: ExecuteOptions): boolean {
|
|
return Array.isArray(options.allowedTools) && options.allowedTools.length === 0;
|
|
}
|
|
|
|
function isSdkEligible(options: ExecuteOptions): boolean {
|
|
return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
|
|
}
|
|
|
|
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
|
|
const cliPath = await findCodexCliPath();
|
|
const authIndicators = await getCodexAuthIndicators();
|
|
const openAiApiKey = await resolveOpenAiApiKey();
|
|
const hasApiKey = Boolean(openAiApiKey);
|
|
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
|
|
const sdkEligible = isSdkEligible(options);
|
|
const cliAvailable = Boolean(cliPath);
|
|
|
|
if (hasApiKey) {
|
|
return {
|
|
mode: CODEX_EXECUTION_MODE_SDK,
|
|
cliPath,
|
|
openAiApiKey,
|
|
};
|
|
}
|
|
|
|
if (sdkEligible) {
|
|
if (!cliAvailable) {
|
|
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
|
|
}
|
|
}
|
|
|
|
if (!cliAvailable) {
|
|
throw new Error(ERROR_CODEX_CLI_REQUIRED);
|
|
}
|
|
|
|
if (!cliAuthenticated) {
|
|
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
|
|
}
|
|
|
|
return {
|
|
mode: CODEX_EXECUTION_MODE_CLI,
|
|
cliPath,
|
|
openAiApiKey,
|
|
};
|
|
}
|
|
|
|
function getEventType(event: Record<string, unknown>): string | null {
|
|
if (typeof event.type === 'string') {
|
|
return event.type;
|
|
}
|
|
if (typeof event.event === 'string') {
|
|
return event.event;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractText(value: unknown): string | null {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.map((item) => extractText(item))
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const record = value as Record<string, unknown>;
|
|
if (typeof record.text === 'string') {
|
|
return record.text;
|
|
}
|
|
if (typeof record.content === 'string') {
|
|
return record.content;
|
|
}
|
|
if (typeof record.message === 'string') {
|
|
return record.message;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractCommandText(item: Record<string, unknown>): string | null {
|
|
const direct = extractText(item.command ?? item.input ?? item.content);
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractCommandOutput(item: Record<string, unknown>): string | null {
|
|
const outputs: string[] = [];
|
|
for (const field of COMMAND_OUTPUT_FIELDS) {
|
|
const value = item[field];
|
|
const text = extractText(value);
|
|
if (text) {
|
|
outputs.push(text);
|
|
}
|
|
}
|
|
|
|
if (outputs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const uniqueOutputs = outputs.filter((output, index) => outputs.indexOf(output) === index);
|
|
return uniqueOutputs.join(COMMAND_OUTPUT_SEPARATOR);
|
|
}
|
|
|
|
function extractItemType(item: Record<string, unknown>): string | null {
|
|
if (typeof item.type === 'string') {
|
|
return item.type;
|
|
}
|
|
if (typeof item.kind === 'string') {
|
|
return item.kind;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveSystemPrompt(systemPrompt?: unknown): string | null {
|
|
if (!systemPrompt) {
|
|
return null;
|
|
}
|
|
if (typeof systemPrompt === 'string') {
|
|
return systemPrompt;
|
|
}
|
|
if (typeof systemPrompt === 'object' && systemPrompt !== null) {
|
|
const record = systemPrompt as Record<string, unknown>;
|
|
if (typeof record.append === 'string') {
|
|
return record.append;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
|
|
const promptText =
|
|
typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt);
|
|
const historyText = options.conversationHistory
|
|
? formatHistoryAsText(options.conversationHistory)
|
|
: '';
|
|
const resolvedSystemPrompt = systemPromptText ?? resolveSystemPrompt(options.systemPrompt);
|
|
|
|
const systemSection = resolvedSystemPrompt
|
|
? `${SYSTEM_PROMPT_LABEL}:\n${resolvedSystemPrompt}\n\n`
|
|
: '';
|
|
|
|
return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
|
|
}
|
|
|
|
function formatConfigValue(value: string | number | boolean): string {
|
|
return String(value);
|
|
}
|
|
|
|
function buildConfigOverrides(
|
|
overrides: Array<{ key: string; value: string | number | boolean }>
|
|
): string[] {
|
|
const args: string[] = [];
|
|
for (const override of overrides) {
|
|
args.push(CODEX_CONFIG_FLAG, `${override.key}=${formatConfigValue(override.value)}`);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function resolveMaxTurns(maxTurns?: number): number | null {
|
|
if (typeof maxTurns !== 'number' || Number.isNaN(maxTurns) || !Number.isFinite(maxTurns)) {
|
|
return null;
|
|
}
|
|
const normalized = Math.floor(maxTurns);
|
|
return normalized >= MIN_MAX_TURNS ? normalized : null;
|
|
}
|
|
|
|
function resolveSearchEnabled(allowedTools: string[], restrictTools: boolean): boolean {
|
|
const toolsToCheck = restrictTools ? allowedTools : Array.from(DEFAULT_ALLOWED_TOOLS);
|
|
return toolsToCheck.some((tool) => SEARCH_TOOL_NAMES.has(tool));
|
|
}
|
|
|
|
function buildCodexConstraintsPrompt(
|
|
options: ExecuteOptions,
|
|
config: {
|
|
allowedTools: string[];
|
|
restrictTools: boolean;
|
|
maxTurns: number | null;
|
|
hasOutputSchema: boolean;
|
|
}
|
|
): string | null {
|
|
const lines: string[] = [];
|
|
|
|
if (config.maxTurns !== null) {
|
|
lines.push(`${CONSTRAINTS_MAX_TURNS_LABEL}: ${config.maxTurns}`);
|
|
}
|
|
|
|
if (config.restrictTools) {
|
|
const allowed =
|
|
config.allowedTools.length > 0 ? config.allowedTools.join(', ') : CONSTRAINTS_NO_TOOLS_VALUE;
|
|
lines.push(`${CONSTRAINTS_ALLOWED_TOOLS_LABEL}: ${allowed}`);
|
|
}
|
|
|
|
if (config.hasOutputSchema) {
|
|
lines.push(`${CONSTRAINTS_OUTPUT_SCHEMA_LABEL}: ${CONSTRAINTS_OUTPUT_SCHEMA_VALUE}`);
|
|
}
|
|
|
|
if (options.sdkSessionId) {
|
|
lines.push(`${CONSTRAINTS_SESSION_ID_LABEL}: ${options.sdkSessionId}`);
|
|
}
|
|
|
|
if (lines.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return `## ${CONSTRAINTS_SECTION_TITLE}\n${lines.map((line) => `- ${line}`).join('\n')}`;
|
|
}
|
|
|
|
async function writeOutputSchemaFile(
|
|
cwd: string,
|
|
outputFormat?: ExecuteOptions['outputFormat']
|
|
): Promise<string | null> {
|
|
if (!outputFormat || outputFormat.type !== 'json_schema') {
|
|
return null;
|
|
}
|
|
if (!outputFormat.schema || typeof outputFormat.schema !== 'object') {
|
|
throw new Error('Codex output schema must be a JSON object.');
|
|
}
|
|
|
|
const schemaDir = path.join(cwd, CODEX_INSTRUCTIONS_DIR);
|
|
await secureFs.mkdir(schemaDir, { recursive: true });
|
|
const schemaPath = path.join(schemaDir, OUTPUT_SCHEMA_FILENAME);
|
|
const schemaContent = JSON.stringify(outputFormat.schema, null, OUTPUT_SCHEMA_INDENT_SPACES);
|
|
await secureFs.writeFile(schemaPath, schemaContent, TEXT_ENCODING);
|
|
return schemaPath;
|
|
}
|
|
|
|
type ImageBlock = {
|
|
type: 'image';
|
|
source: {
|
|
type: string;
|
|
media_type: string;
|
|
data: string;
|
|
};
|
|
};
|
|
|
|
function extractImageBlocks(prompt: ExecuteOptions['prompt']): ImageBlock[] {
|
|
if (typeof prompt === 'string') {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(prompt)) {
|
|
return [];
|
|
}
|
|
|
|
const images: ImageBlock[] = [];
|
|
for (const block of prompt) {
|
|
if (
|
|
block &&
|
|
typeof block === 'object' &&
|
|
'type' in block &&
|
|
block.type === 'image' &&
|
|
'source' in block &&
|
|
block.source &&
|
|
typeof block.source === 'object' &&
|
|
'data' in block.source &&
|
|
'media_type' in block.source
|
|
) {
|
|
images.push(block as ImageBlock);
|
|
}
|
|
}
|
|
return images;
|
|
}
|
|
|
|
async function writeImageFiles(cwd: string, imageBlocks: ImageBlock[]): Promise<string[]> {
|
|
if (imageBlocks.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const imageDir = path.join(cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
|
|
await secureFs.mkdir(imageDir, { recursive: true });
|
|
|
|
const imagePaths: string[] = [];
|
|
for (let i = 0; i < imageBlocks.length; i++) {
|
|
const imageBlock = imageBlocks[i];
|
|
const imageName = `${IMAGE_FILE_PREFIX}${Date.now()}-${i}${IMAGE_FILE_EXT}`;
|
|
const imagePath = path.join(imageDir, imageName);
|
|
|
|
// Convert base64 to buffer
|
|
const imageData = Buffer.from(imageBlock.source.data, 'base64');
|
|
await secureFs.writeFile(imagePath, imageData);
|
|
imagePaths.push(imagePath);
|
|
}
|
|
|
|
return imagePaths;
|
|
}
|
|
|
|
function normalizeIdentifier(value: unknown): string | null {
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return String(value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getIdentifierFromRecord(
|
|
record: Record<string, unknown>,
|
|
keys: readonly string[]
|
|
): string | null {
|
|
for (const key of keys) {
|
|
const id = normalizeIdentifier(record[key]);
|
|
if (id) {
|
|
return id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getItemIdentifier(
|
|
event: Record<string, unknown>,
|
|
item: Record<string, unknown>
|
|
): string | null {
|
|
return (
|
|
getIdentifierFromRecord(item, ITEM_ID_KEYS) ?? getIdentifierFromRecord(event, EVENT_ID_KEYS)
|
|
);
|
|
}
|
|
|
|
class CodexToolUseTracker {
|
|
private readonly toolUseIdsByItem = new Map<string, string>();
|
|
private readonly anonymousToolUses: string[] = [];
|
|
private sequence = 0;
|
|
|
|
register(event: Record<string, unknown>, item: Record<string, unknown>): string {
|
|
const itemId = getItemIdentifier(event, item);
|
|
const toolUseId = this.nextToolUseId();
|
|
if (itemId) {
|
|
this.toolUseIdsByItem.set(itemId, toolUseId);
|
|
} else {
|
|
this.anonymousToolUses.push(toolUseId);
|
|
}
|
|
return toolUseId;
|
|
}
|
|
|
|
resolve(event: Record<string, unknown>, item: Record<string, unknown>): string | null {
|
|
const itemId = getItemIdentifier(event, item);
|
|
if (itemId) {
|
|
const toolUseId = this.toolUseIdsByItem.get(itemId);
|
|
if (toolUseId) {
|
|
this.toolUseIdsByItem.delete(itemId);
|
|
return toolUseId;
|
|
}
|
|
}
|
|
|
|
if (this.anonymousToolUses.length > 0) {
|
|
return this.anonymousToolUses.shift() || null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private nextToolUseId(): string {
|
|
this.sequence += 1;
|
|
return `${TOOL_USE_ID_PREFIX}${this.sequence}`;
|
|
}
|
|
}
|
|
|
|
type CodexCliSettings = {
|
|
autoLoadAgents: boolean;
|
|
sandboxMode: CodexSandboxMode;
|
|
approvalPolicy: CodexApprovalPolicy;
|
|
enableWebSearch: boolean;
|
|
enableImages: boolean;
|
|
additionalDirs: string[];
|
|
threadId?: string;
|
|
};
|
|
|
|
function getCodexSettingsDir(): string {
|
|
const configured = getDataDirectory() ?? process.env.DATA_DIR;
|
|
return configured ? path.resolve(configured) : path.resolve(CODEX_SETTINGS_DIR_FALLBACK);
|
|
}
|
|
|
|
async function loadCodexCliSettings(
|
|
overrides?: ExecuteOptions['codexSettings']
|
|
): Promise<CodexCliSettings> {
|
|
const defaults: CodexCliSettings = {
|
|
autoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS,
|
|
sandboxMode: DEFAULT_CODEX_SANDBOX_MODE,
|
|
approvalPolicy: DEFAULT_CODEX_APPROVAL_POLICY,
|
|
enableWebSearch: false,
|
|
enableImages: true,
|
|
additionalDirs: [],
|
|
threadId: undefined,
|
|
};
|
|
|
|
try {
|
|
const settingsService = new SettingsService(getCodexSettingsDir());
|
|
const settings = await settingsService.getGlobalSettings();
|
|
const resolved: CodexCliSettings = {
|
|
autoLoadAgents: settings.codexAutoLoadAgents ?? defaults.autoLoadAgents,
|
|
sandboxMode: settings.codexSandboxMode ?? defaults.sandboxMode,
|
|
approvalPolicy: settings.codexApprovalPolicy ?? defaults.approvalPolicy,
|
|
enableWebSearch: settings.codexEnableWebSearch ?? defaults.enableWebSearch,
|
|
enableImages: settings.codexEnableImages ?? defaults.enableImages,
|
|
additionalDirs: settings.codexAdditionalDirs ?? defaults.additionalDirs,
|
|
threadId: settings.codexThreadId,
|
|
};
|
|
|
|
if (!overrides) {
|
|
return resolved;
|
|
}
|
|
|
|
return {
|
|
autoLoadAgents: overrides.autoLoadAgents ?? resolved.autoLoadAgents,
|
|
sandboxMode: overrides.sandboxMode ?? resolved.sandboxMode,
|
|
approvalPolicy: overrides.approvalPolicy ?? resolved.approvalPolicy,
|
|
enableWebSearch: overrides.enableWebSearch ?? resolved.enableWebSearch,
|
|
enableImages: overrides.enableImages ?? resolved.enableImages,
|
|
additionalDirs: overrides.additionalDirs ?? resolved.additionalDirs,
|
|
threadId: overrides.threadId ?? resolved.threadId,
|
|
};
|
|
} catch {
|
|
return {
|
|
autoLoadAgents: overrides?.autoLoadAgents ?? defaults.autoLoadAgents,
|
|
sandboxMode: overrides?.sandboxMode ?? defaults.sandboxMode,
|
|
approvalPolicy: overrides?.approvalPolicy ?? defaults.approvalPolicy,
|
|
enableWebSearch: overrides?.enableWebSearch ?? defaults.enableWebSearch,
|
|
enableImages: overrides?.enableImages ?? defaults.enableImages,
|
|
additionalDirs: overrides?.additionalDirs ?? defaults.additionalDirs,
|
|
threadId: overrides?.threadId ?? defaults.threadId,
|
|
};
|
|
}
|
|
}
|
|
|
|
function buildCodexInstructionsPrompt(
|
|
filePath: string,
|
|
content: string,
|
|
sourceLabel: string
|
|
): string {
|
|
return `## ${CODEX_INSTRUCTIONS_SECTION}\n**${CODEX_INSTRUCTIONS_SOURCE_LABEL}:** ${sourceLabel}\n**${CODEX_INSTRUCTIONS_PATH_LABEL}:** \`${filePath}\`\n\n${content}`;
|
|
}
|
|
|
|
async function readCodexInstructionFile(filePath: string): Promise<string | null> {
|
|
try {
|
|
const raw = await secureFs.readFile(filePath, TEXT_ENCODING);
|
|
const content = String(raw).trim();
|
|
return content ? content : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadCodexInstructions(cwd: string, enabled: boolean): Promise<string | null> {
|
|
if (!enabled) {
|
|
return null;
|
|
}
|
|
|
|
const sources: Array<{ path: string; content: string; sourceLabel: string }> = [];
|
|
const userInstructionsPath = path.join(getCodexConfigDir(), CODEX_USER_INSTRUCTIONS_FILE);
|
|
const userContent = await readCodexInstructionFile(userInstructionsPath);
|
|
if (userContent) {
|
|
sources.push({
|
|
path: userInstructionsPath,
|
|
content: userContent,
|
|
sourceLabel: CODEX_INSTRUCTIONS_USER_SOURCE,
|
|
});
|
|
}
|
|
|
|
for (const fileName of CODEX_PROJECT_INSTRUCTIONS_FILES) {
|
|
const projectPath = path.join(cwd, CODEX_INSTRUCTIONS_DIR, fileName);
|
|
const projectContent = await readCodexInstructionFile(projectPath);
|
|
if (projectContent) {
|
|
sources.push({
|
|
path: projectPath,
|
|
content: projectContent,
|
|
sourceLabel: CODEX_INSTRUCTIONS_PROJECT_SOURCE,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (sources.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
const uniqueSources = sources.filter((source) => {
|
|
const normalized = source.content.trim();
|
|
if (seen.has(normalized)) {
|
|
return false;
|
|
}
|
|
seen.add(normalized);
|
|
return true;
|
|
});
|
|
|
|
return uniqueSources
|
|
.map((source) => buildCodexInstructionsPrompt(source.path, source.content, source.sourceLabel))
|
|
.join('\n\n');
|
|
}
|
|
|
|
const logger = createLogger('CodexProvider');
|
|
|
|
export class CodexProvider extends BaseProvider {
|
|
getName(): string {
|
|
return 'codex';
|
|
}
|
|
|
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
|
// Validate that model doesn't have a provider prefix
|
|
// AgentService should strip prefixes before passing to providers
|
|
validateBareModelId(options.model, 'CodexProvider');
|
|
|
|
try {
|
|
const mcpServers = options.mcpServers ?? {};
|
|
const hasMcpServers = Object.keys(mcpServers).length > 0;
|
|
const codexSettings = await loadCodexCliSettings(options.codexSettings);
|
|
const codexInstructions = await loadCodexInstructions(
|
|
options.cwd,
|
|
codexSettings.autoLoadAgents
|
|
);
|
|
const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt);
|
|
const resolvedMaxTurns = resolveMaxTurns(options.maxTurns);
|
|
const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS);
|
|
const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false;
|
|
const wantsOutputSchema = Boolean(
|
|
options.outputFormat && options.outputFormat.type === 'json_schema'
|
|
);
|
|
const constraintsPrompt = buildCodexConstraintsPrompt(options, {
|
|
allowedTools: resolvedAllowedTools,
|
|
restrictTools,
|
|
maxTurns: resolvedMaxTurns,
|
|
hasOutputSchema: wantsOutputSchema,
|
|
});
|
|
const systemPromptParts = [codexInstructions, baseSystemPrompt, constraintsPrompt].filter(
|
|
(part): part is string => Boolean(part)
|
|
);
|
|
const combinedSystemPrompt = systemPromptParts.length
|
|
? systemPromptParts.join(SYSTEM_PROMPT_SEPARATOR)
|
|
: null;
|
|
|
|
const executionPlan = await resolveCodexExecutionPlan(options);
|
|
if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) {
|
|
const cleanupEnv = executionPlan.openAiApiKey
|
|
? createTempEnvOverride({ [OPENAI_API_KEY_ENV]: executionPlan.openAiApiKey })
|
|
: null;
|
|
try {
|
|
yield* executeCodexSdkQuery(options, combinedSystemPrompt);
|
|
} finally {
|
|
cleanupEnv?.();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (hasMcpServers) {
|
|
const configManager = new CodexConfigManager();
|
|
await configManager.configureMcpServers(options.cwd, options.mcpServers!);
|
|
}
|
|
|
|
const toolUseTracker = new CodexToolUseTracker();
|
|
const sandboxCheck = checkSandboxCompatibility(
|
|
options.cwd,
|
|
codexSettings.sandboxMode !== 'danger-full-access'
|
|
);
|
|
const resolvedSandboxMode = sandboxCheck.enabled
|
|
? codexSettings.sandboxMode
|
|
: 'danger-full-access';
|
|
if (!sandboxCheck.enabled && sandboxCheck.message) {
|
|
console.warn(`[CodexProvider] ${sandboxCheck.message}`);
|
|
}
|
|
const searchEnabled =
|
|
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
|
const outputSchemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
|
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
|
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
|
|
const approvalPolicy =
|
|
hasMcpServers && options.mcpAutoApproveTools !== undefined
|
|
? options.mcpAutoApproveTools
|
|
? 'never'
|
|
: 'on-request'
|
|
: codexSettings.approvalPolicy;
|
|
const promptText = buildCombinedPrompt(options, combinedSystemPrompt);
|
|
const commandPath = executionPlan.cliPath || CODEX_COMMAND;
|
|
|
|
// Build config overrides for max turns and reasoning effort
|
|
const overrides: Array<{ key: string; value: string | number | boolean }> = [];
|
|
if (resolvedMaxTurns !== null) {
|
|
overrides.push({ key: CONFIG_KEY_MAX_TURNS, value: resolvedMaxTurns });
|
|
}
|
|
|
|
// Add reasoning effort if model supports it and reasoningEffort is specified
|
|
if (
|
|
options.reasoningEffort &&
|
|
supportsReasoningEffort(options.model) &&
|
|
options.reasoningEffort !== 'none'
|
|
) {
|
|
overrides.push({ key: CODEX_REASONING_EFFORT_KEY, value: options.reasoningEffort });
|
|
}
|
|
|
|
// Add approval policy
|
|
overrides.push({ key: 'approval_policy', value: approvalPolicy });
|
|
|
|
// Add web search if enabled
|
|
if (searchEnabled) {
|
|
overrides.push({ key: 'features.web_search_request', value: true });
|
|
}
|
|
|
|
const configOverrides = buildConfigOverrides(overrides);
|
|
const preExecArgs: string[] = [];
|
|
|
|
// Add additional directories with write access
|
|
if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) {
|
|
for (const dir of codexSettings.additionalDirs) {
|
|
preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
|
|
}
|
|
}
|
|
|
|
// Model is already bare (no prefix) - validated by executeQuery
|
|
const args = [
|
|
CODEX_EXEC_SUBCOMMAND,
|
|
CODEX_YOLO_FLAG,
|
|
CODEX_SKIP_GIT_REPO_CHECK_FLAG,
|
|
...preExecArgs,
|
|
CODEX_MODEL_FLAG,
|
|
options.model,
|
|
CODEX_JSON_FLAG,
|
|
'-', // Read prompt from stdin to avoid shell escaping issues
|
|
];
|
|
|
|
const envOverrides = buildEnv();
|
|
if (executionPlan.openAiApiKey && !envOverrides[OPENAI_API_KEY_ENV]) {
|
|
envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey;
|
|
}
|
|
|
|
// Calculate dynamic timeout based on reasoning effort.
|
|
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
|
|
// for the model to generate reasoning tokens before producing output.
|
|
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
|
|
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
|
|
|
|
const stream = spawnJSONLProcess({
|
|
command: commandPath,
|
|
args,
|
|
cwd: options.cwd,
|
|
env: envOverrides,
|
|
abortController: options.abortController,
|
|
timeout,
|
|
stdinData: promptText, // Pass prompt via stdin
|
|
});
|
|
|
|
for await (const rawEvent of stream) {
|
|
const event = rawEvent as Record<string, unknown>;
|
|
const eventType = getEventType(event);
|
|
|
|
// Track thread/session ID from events
|
|
const threadId = event.thread_id;
|
|
if (threadId && typeof threadId === 'string') {
|
|
this._lastSessionId = threadId;
|
|
}
|
|
|
|
if (eventType === CODEX_EVENT_TYPES.error) {
|
|
const errorText = extractText(event.error ?? event.message) || 'Codex CLI error';
|
|
|
|
// Enhance error message with helpful context
|
|
let enhancedError = errorText;
|
|
if (errorText.toLowerCase().includes('rate limit')) {
|
|
enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
|
|
} else if (
|
|
errorText.toLowerCase().includes('authentication') ||
|
|
errorText.toLowerCase().includes('unauthorized')
|
|
) {
|
|
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex auth login' to authenticate.`;
|
|
} else if (
|
|
errorText.toLowerCase().includes('not found') ||
|
|
errorText.toLowerCase().includes('command not found')
|
|
) {
|
|
enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
|
|
}
|
|
|
|
console.error('[CodexProvider] CLI error event:', { errorText, event });
|
|
yield { type: 'error', error: enhancedError };
|
|
continue;
|
|
}
|
|
|
|
if (eventType === CODEX_EVENT_TYPES.turnCompleted) {
|
|
const resultText = extractText(event.result) || undefined;
|
|
yield { type: 'result', subtype: 'success', result: resultText };
|
|
continue;
|
|
}
|
|
|
|
if (!eventType) {
|
|
const fallbackText = extractText(event);
|
|
if (fallbackText) {
|
|
yield {
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: fallbackText }],
|
|
},
|
|
};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const item = (event.item ?? {}) as Record<string, unknown>;
|
|
const itemType = extractItemType(item);
|
|
|
|
if (
|
|
eventType === CODEX_EVENT_TYPES.itemStarted &&
|
|
itemType === CODEX_ITEM_TYPES.commandExecution
|
|
) {
|
|
const commandText = extractCommandText(item) || '';
|
|
const tool = resolveCodexToolCall(commandText);
|
|
const toolUseId = toolUseTracker.register(event, item);
|
|
yield {
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
name: tool.name,
|
|
input: tool.input,
|
|
tool_use_id: toolUseId,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
continue;
|
|
}
|
|
|
|
if (eventType === CODEX_EVENT_TYPES.itemUpdated && itemType === CODEX_ITEM_TYPES.todoList) {
|
|
const todos = extractCodexTodoItems(item);
|
|
if (todos) {
|
|
yield {
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
name: getCodexTodoToolName(),
|
|
input: { todos },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
} else {
|
|
const todoText = extractText(item) || '';
|
|
const formatted = todoText ? `Updated TODO list:\n${todoText}` : 'Updated TODO list';
|
|
yield {
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: formatted }],
|
|
},
|
|
};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (eventType === CODEX_EVENT_TYPES.itemCompleted) {
|
|
if (itemType === CODEX_ITEM_TYPES.reasoning) {
|
|
const thinkingText = extractText(item) || '';
|
|
yield {
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'thinking', thinking: thinkingText }],
|
|
},
|
|
};
|
|
continue;
|
|
}
|
|
|
|
if (itemType === CODEX_ITEM_TYPES.commandExecution) {
|
|
const commandOutput =
|
|
extractCommandOutput(item) ?? extractCommandText(item) ?? extractText(item) ?? '';
|
|
if (commandOutput) {
|
|
const toolUseId = toolUseTracker.resolve(event, item);
|
|
const toolResultBlock: {
|
|
type: 'tool_result';
|
|
content: string;
|
|
tool_use_id?: string;
|
|
} = { type: 'tool_result', content: commandOutput };
|
|
if (toolUseId) {
|
|
toolResultBlock.tool_use_id = toolUseId;
|
|
}
|
|
yield {
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [toolResultBlock],
|
|
},
|
|
};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const text = extractText(item) || extractText(event);
|
|
if (text) {
|
|
yield {
|
|
type: 'assistant',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text }],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorInfo = classifyError(error);
|
|
const userMessage = getUserFriendlyErrorMessage(error);
|
|
const enhancedMessage = errorInfo.isRateLimit
|
|
? `${userMessage}\n\nTip: If you're rate limited, try reducing concurrent tasks or waiting a few minutes.`
|
|
: userMessage;
|
|
|
|
console.error('[CodexProvider] executeQuery() error:', {
|
|
type: errorInfo.type,
|
|
message: errorInfo.message,
|
|
isRateLimit: errorInfo.isRateLimit,
|
|
retryAfter: errorInfo.retryAfter,
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
|
|
yield { type: 'error', error: enhancedMessage };
|
|
}
|
|
}
|
|
|
|
async detectInstallation(): Promise<InstallationStatus> {
|
|
const cliPath = await findCodexCliPath();
|
|
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
|
const authIndicators = await getCodexAuthIndicators();
|
|
const installed = !!cliPath;
|
|
|
|
let version = '';
|
|
if (installed) {
|
|
try {
|
|
const result = await spawnProcess({
|
|
command: cliPath || CODEX_COMMAND,
|
|
args: [CODEX_VERSION_FLAG],
|
|
cwd: process.cwd(),
|
|
});
|
|
version = result.stdout.trim();
|
|
} catch (error) {
|
|
version = '';
|
|
}
|
|
}
|
|
|
|
// Determine auth status - always verify with CLI, never assume authenticated
|
|
const authCheck = await checkCodexAuthentication(cliPath);
|
|
const authenticated = authCheck.authenticated;
|
|
|
|
return {
|
|
installed,
|
|
path: cliPath || undefined,
|
|
version: version || undefined,
|
|
method: 'cli' as const, // Installation method
|
|
hasApiKey,
|
|
authenticated,
|
|
};
|
|
}
|
|
|
|
getAvailableModels(): ModelDefinition[] {
|
|
// Return all available Codex/OpenAI models
|
|
return CODEX_MODELS;
|
|
}
|
|
|
|
/**
|
|
* Check authentication status for Codex CLI
|
|
*/
|
|
async checkAuth(): Promise<CodexAuthStatus> {
|
|
const cliPath = await findCodexCliPath();
|
|
const hasApiKey = Boolean(await resolveOpenAiApiKey());
|
|
const authIndicators = await getCodexAuthIndicators();
|
|
|
|
// Check for API key in environment
|
|
if (hasApiKey) {
|
|
return { authenticated: true, method: 'api_key' };
|
|
}
|
|
|
|
// Check for OAuth/token from Codex CLI
|
|
if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) {
|
|
return { authenticated: true, method: 'oauth' };
|
|
}
|
|
|
|
// CLI is installed but not authenticated via indicators - try CLI command
|
|
if (cliPath) {
|
|
try {
|
|
// Try 'codex login status' first (same as checkCodexAuthentication)
|
|
const result = await spawnProcess({
|
|
command: cliPath || CODEX_COMMAND,
|
|
args: ['login', 'status'],
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
TERM: 'dumb',
|
|
},
|
|
});
|
|
|
|
// Check both stdout and stderr - Codex CLI outputs to stderr
|
|
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
|
const isLoggedIn = combinedOutput.includes('logged in');
|
|
|
|
if (result.exitCode === 0 && isLoggedIn) {
|
|
return { authenticated: true, method: 'oauth' };
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Error running login status command during auth check:', error);
|
|
}
|
|
}
|
|
|
|
return { authenticated: false, method: 'none' };
|
|
}
|
|
|
|
/**
|
|
* Get the detected CLI path (public accessor for status endpoints)
|
|
*/
|
|
async getCliPath(): Promise<string | null> {
|
|
const path = await findCodexCliPath();
|
|
return path || null;
|
|
}
|
|
|
|
/**
|
|
* Get the last CLI session ID (for tracking across queries)
|
|
* This can be used to resume sessions in subsequent requests
|
|
*/
|
|
getLastSessionId(): string | null {
|
|
return this._lastSessionId ?? null;
|
|
}
|
|
|
|
/**
|
|
* Set a session ID to use for CLI session resumption
|
|
*/
|
|
setSessionId(sessionId: string | null): void {
|
|
this._lastSessionId = sessionId;
|
|
}
|
|
|
|
private _lastSessionId: string | null = null;
|
|
}
|