mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: enhance Codex authentication and API key management
- Introduced a new method to check Codex authentication status, allowing for better handling of API keys and OAuth tokens. - Updated API key management to include OpenAI, enabling users to manage their keys more effectively. - Enhanced the CodexProvider to support session ID tracking and deduplication of text blocks in assistant messages. - Improved error handling and logging in authentication routes, providing clearer feedback to users. These changes improve the overall user experience and security of the Codex integration, ensuring smoother authentication processes and better management of API keys.
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
supportsReasoningEffort,
|
||||
type CodexApprovalPolicy,
|
||||
type CodexSandboxMode,
|
||||
type CodexAuthStatus,
|
||||
} from '@automaker/types';
|
||||
import { CodexConfigManager } from './codex-config-manager.js';
|
||||
import { executeCodexSdkQuery } from './codex-sdk-client.js';
|
||||
@@ -56,6 +57,7 @@ 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 OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
@@ -742,7 +744,7 @@ export class CodexProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
const configOverrides = buildConfigOverrides(overrides);
|
||||
const globalArgs = [CODEX_APPROVAL_FLAG, approvalPolicy];
|
||||
const globalArgs = [CODEX_SKIP_GIT_REPO_CHECK_FLAG, CODEX_APPROVAL_FLAG, approvalPolicy];
|
||||
if (searchEnabled) {
|
||||
globalArgs.push(CODEX_SEARCH_FLAG);
|
||||
}
|
||||
@@ -782,6 +784,12 @@ export class CodexProvider extends BaseProvider {
|
||||
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';
|
||||
|
||||
@@ -985,4 +993,121 @@ export class CodexProvider extends BaseProvider {
|
||||
// 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 = !!process.env[OPENAI_API_KEY_ENV];
|
||||
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
|
||||
if (cliPath) {
|
||||
try {
|
||||
const result = await spawnProcess({
|
||||
command: cliPath || CODEX_COMMAND,
|
||||
args: ['auth', 'status', '--json'],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
// If auth command succeeds, we're authenticated
|
||||
if (result.exitCode === 0) {
|
||||
return { authenticated: true, method: 'oauth' };
|
||||
}
|
||||
} catch {
|
||||
// Auth command failed, not authenticated
|
||||
}
|
||||
}
|
||||
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate text blocks in Codex assistant messages
|
||||
*
|
||||
* Codex can send:
|
||||
* 1. Duplicate consecutive text blocks (same text twice in a row)
|
||||
* 2. A final accumulated block containing ALL previous text
|
||||
*
|
||||
* This method filters out these duplicates to prevent UI stuttering.
|
||||
*/
|
||||
private deduplicateTextBlocks(
|
||||
content: Array<{ type: string; text?: string }>,
|
||||
lastTextBlock: string,
|
||||
accumulatedText: string
|
||||
): { content: Array<{ type: string; text?: string }>; lastBlock: string; accumulated: string } {
|
||||
const filtered: Array<{ type: string; text?: string }> = [];
|
||||
let newLastBlock = lastTextBlock;
|
||||
let newAccumulated = accumulatedText;
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type !== 'text' || !block.text) {
|
||||
filtered.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = block.text;
|
||||
|
||||
// Skip empty text
|
||||
if (!text.trim()) continue;
|
||||
|
||||
// Skip duplicate consecutive text blocks
|
||||
if (text === newLastBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip final accumulated text block
|
||||
// Codex sends one large block containing ALL previous text at the end
|
||||
if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) {
|
||||
const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim();
|
||||
const normalizedNew = text.replace(/\s+/g, ' ').trim();
|
||||
if (normalizedNew.includes(normalizedAccum.slice(0, 100))) {
|
||||
// This is the final accumulated block, skip it
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// This is a valid new text block
|
||||
newLastBlock = text;
|
||||
newAccumulated += text;
|
||||
filtered.push(block);
|
||||
}
|
||||
|
||||
return { content: filtered, lastBlock: newLastBlock, accumulated: newAccumulated };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const TOOL_NAME_WRITE = 'Write';
|
||||
const TOOL_NAME_GREP = 'Grep';
|
||||
const TOOL_NAME_GLOB = 'Glob';
|
||||
const TOOL_NAME_TODO = 'TodoWrite';
|
||||
const TOOL_NAME_DELETE = 'Delete';
|
||||
const TOOL_NAME_LS = 'Ls';
|
||||
|
||||
const INPUT_KEY_COMMAND = 'command';
|
||||
const INPUT_KEY_FILE_PATH = 'file_path';
|
||||
@@ -37,6 +39,8 @@ const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']);
|
||||
const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']);
|
||||
const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']);
|
||||
const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']);
|
||||
const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']);
|
||||
const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']);
|
||||
const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']);
|
||||
const APPLY_PATCH_COMMAND = 'apply_patch';
|
||||
const APPLY_PATCH_PATTERN = /\bapply_patch\b/;
|
||||
@@ -193,6 +197,18 @@ function extractRedirectionTarget(command: string): string | null {
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractFilePathFromDeleteTokens(tokens: string[]): string | null {
|
||||
// rm file.txt or rm /path/to/file.txt
|
||||
// Skip flags and get the first non-flag argument
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (token && !token.startsWith('-')) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSedInPlaceFlag(tokens: string[]): boolean {
|
||||
return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i'));
|
||||
}
|
||||
@@ -279,6 +295,41 @@ export function resolveCodexToolCall(command: string): CodexToolResolution {
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Delete commands (rm, del, erase, remove, unlink)
|
||||
if (DELETE_COMMANDS.has(commandToken)) {
|
||||
// Skip if -r or -rf flags (recursive delete should go to Bash)
|
||||
if (
|
||||
tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf')
|
||||
) {
|
||||
return {
|
||||
name: TOOL_NAME_BASH,
|
||||
input: { [INPUT_KEY_COMMAND]: normalized },
|
||||
};
|
||||
}
|
||||
// Simple file deletion - extract the file path
|
||||
const filePath = extractFilePathFromDeleteTokens(tokens);
|
||||
if (filePath) {
|
||||
return {
|
||||
name: TOOL_NAME_DELETE,
|
||||
input: { path: filePath },
|
||||
};
|
||||
}
|
||||
// Fall back to bash if we can't determine the file path
|
||||
return {
|
||||
name: TOOL_NAME_BASH,
|
||||
input: { [INPUT_KEY_COMMAND]: normalized },
|
||||
};
|
||||
}
|
||||
|
||||
// Handle simple Ls commands (just listing, not find/glob)
|
||||
if (LIST_COMMANDS.has(commandToken)) {
|
||||
const filePath = extractFilePathFromTokens(tokens);
|
||||
return {
|
||||
name: TOOL_NAME_LS,
|
||||
input: { path: filePath || '.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (GLOB_COMMANDS.has(commandToken)) {
|
||||
return {
|
||||
name: TOOL_NAME_GLOB,
|
||||
|
||||
@@ -173,12 +173,21 @@ export class ProviderFactory {
|
||||
model.id === modelId ||
|
||||
model.modelString === modelId ||
|
||||
model.id.endsWith(`-${modelId}`) ||
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '')
|
||||
model.modelString.endsWith(`-${modelId}`) ||
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
|
||||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
|
||||
) {
|
||||
return model.supportsVision ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also try exact match with model string from provider's model map
|
||||
for (const model of models) {
|
||||
if (model.modelString === modelId || model.id === modelId) {
|
||||
return model.supportsVision ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to true (Claude SDK supports vision by default)
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createApiKeysHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
|
||||
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get API keys failed');
|
||||
|
||||
@@ -46,13 +46,14 @@ export function createDeleteApiKeyHandler() {
|
||||
// Map provider to env key name
|
||||
const envKeyMap: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
};
|
||||
|
||||
const envKey = envKeyMap[provider];
|
||||
if (!envKey) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
|
||||
error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ function isRateLimitError(text: string): boolean {
|
||||
|
||||
export function createVerifyCodexAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' };
|
||||
const { authMethod, apiKey } = req.body as {
|
||||
authMethod?: 'cli' | 'api_key';
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
// Create session ID for cleanup
|
||||
const sessionId = `codex-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
@@ -105,21 +108,32 @@ export function createVerifyCodexAuthHandler() {
|
||||
|
||||
try {
|
||||
// Create secure environment without modifying process.env
|
||||
const authEnv = createSecureAuthEnv(authMethod || 'api_key', undefined, 'openai');
|
||||
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai');
|
||||
|
||||
// For API key auth, use stored key
|
||||
// For API key auth, validate and use the provided key or stored key
|
||||
if (authMethod === 'api_key') {
|
||||
const storedApiKey = getApiKey('openai');
|
||||
if (storedApiKey) {
|
||||
const validation = validateApiKey(storedApiKey, 'openai');
|
||||
if (apiKey) {
|
||||
// Use the provided API key
|
||||
const validation = validateApiKey(apiKey, 'openai');
|
||||
if (!validation.isValid) {
|
||||
res.json({ success: true, authenticated: false, error: validation.error });
|
||||
return;
|
||||
}
|
||||
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
|
||||
} else if (!authEnv[OPENAI_API_KEY_ENV]) {
|
||||
res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED });
|
||||
return;
|
||||
} else {
|
||||
// Try stored key
|
||||
const storedApiKey = getApiKey('openai');
|
||||
if (storedApiKey) {
|
||||
const validation = validateApiKey(storedApiKey, 'openai');
|
||||
if (!validation.isValid) {
|
||||
res.json({ success: true, authenticated: false, error: validation.error });
|
||||
return;
|
||||
}
|
||||
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
|
||||
} else if (!authEnv[OPENAI_API_KEY_ENV]) {
|
||||
res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user