Files
claudecodeui/server/openai-codex.js
Haile 4cee5e7286 Fix/resolve different bugs (#964)
* fix: don't overlap thinking banner over conversation

* feat: support message queue and add attention indicator

* fix(shell): use c to copy for claude

* fix: strip timestamp tags from cursor

* fix: select project when loading session from direct URL

* feat: support attached images for all providers

* feat(opencode): support permission options

* fix: resolve source control and chat UX bugs

- make staging real in the git panel: new /stage and /unstage endpoints,
  /status now reports the actual index via porcelain -z (handles renames,
  conflicts, and paths with spaces), and Stage/Unstage All run real git
  commands instead of toggling client-side state
- add branch search to the header dropdown and Branches tab
- persist the chosen permission mode per provider so a brand-new chat
  keeps it once the session id arrives, instead of reverting to default
- replace cmdk's fuzzy model search with strict token matching so
  "chatgpt" no longer surfaces unrelated models
- unit tests for the git status parser

* feat(git): commit graph in history view and cross-spawn everywhere

- render a VSCode-style commit graph in the source control history:
  lane-assignment algorithm, SVG strip with colored rails, merge and
  branch curves, commit dots, and branch/tag badges per commit
- /commits returns parents and ref decorations across all branches
  (--branches --remotes --tags --topo-order) using unit-separator
  fields so pipes in commit subjects can't break parsing
- collect stats via a single --shortstat pass instead of one
  `git show --stat` call per commit; raise the history limit to 50
- replace child_process spawn with cross-spawn in all runtimes,
  routes, and services: it resolves .cmd shims and PATHEXT on
  Windows (fixing taskmaster's npx invocations) and delegates to
  native spawn elsewhere, removing the per-file win32 ternaries
- unit tests for the log parser and lane assignment

* fix: remove gemini support since google discontinued it

* fix: address code review findings

- validate chat image attachments server-side: only files inside the
  ~/.cloudcli/assets upload store may reach provider file reads
- harden asset serving with nosniff and attachment disposition for
  SVGs to prevent stored XSS
- show the timestamp on image-only user messages
- serialize git stage/unstage calls and defer the status re-sync so
  rapid toggles can't interleave or flicker
- use a literal hex fallback for commit ref badges (alpha suffix on a
  var() string produced invalid CSS)
- stop binding a URL session to a guening
  project instead
- add the missing attentionRequiredIndicator key to all sidebar locales
- clean up dangling conjunctions left the
  de/ru/tr/ja/zh-CN/zh-TW READMEs

* fix: address code scanning findings

- enforce a second image trust boundary in the provider builders:
  buildClaudeUserContent/buildCodexInputItems only accept files inside
  the upload store or the run's working directory (CodeQL path
  injection)
- drop an always-true selectedProject check in handleSubmit
- remove the unused resume option from spawnCursor

* fix: use CodeQL-recognized containment check for image reads

Replace the path.relative guard in isPathInsideDirectory with the
resolve + startsWith(root + sep) idiom so the path-injection barrier
is visible to code scanning; behavior is unchanged.

* fix(security): canonicalize Claude image attachment reads

Resolve image attachment paths with realpath before reading so symlinks
inside allowed roots cannot escape to arbitrary files. Preserve support for
symlinked project/upload roots and add focused coverage for both cases.

* fix: show agent subtask

* fix(sessions): title app-created sessions from the first user message

App-created sessions (started by sending a message from cloudcli) were
titled with placeholder names — "Untitled Codex Session" from the disk
indexer and, briefly, "New session" from the empty canonical upsert —
before a later sync finally settled on the right text, causing the
sidebar title to flicker.

- Codex/OpenCode synchronizers now title app-created sessions (distinct
  app id mapped to a provider id) from the first user message, while
  sessions found purely by indexing keep their existing setup. Claude
  keeps its AI-generated titles; Cursor already used the first message.
- Decode OpenCode's JSON-string-literal prompts so titles no longer
  surface wrapped in quotes; hoist unwrapJsonStringLiteral into
  shared/utils since it's now used by both the reader and synchronizer.
- Guard the sidebar upsert merge so an empty summary can never blank out
  a title that is already set.
- Add codex/opencode synchronizer tests for the app-created vs indexed
  naming paths.

* fix(chat): make message queuing reliable across sessions and turn boundaries

Queued messages had four related defects:

- A queued message flashed and then vanished at flush time: concurrent
  transcript refreshes (the `complete` handler racing the watcher-triggered
  update) could resolve out of order, letting a stale response overwrite
  newer server messages after the optimistic row was pruned. Session slots
  now carry a monotonic fetch ticket and discard stale fetch/refresh/
  fetchMore responses.

- Switching sessions flushed the previous session's queued draft into the
  newly viewed one (sending it with the wrong provider's settings, e.g. a
  Claude model into a Codex session). The composer flush is now scoped to
  its session, and a draft restored into an idle session sends after a
  short grace period so a live-run ack can cancel it.

- The thinking banner never appeared for a queued turn: the chat handler's
  session-keyed completeRun safety net could fire after a queued message
  had already started the session's next run, emitting a spurious
  `complete` that killed it. The safety net is now scoped to its own run
  via completeRunIfCurrent (with a regression test).

- Queued messages only sent while their session was being viewed. Drafts
  now persist their send options (model, effort, permissions) at queue
  time, and a new app-level useQueuedMessageAutoSend hook dispatches a
  non-viewed session's queued message as soon as its run completes, using
  the storage key as the claim ticket to prevent double sends.
2026-07-08 10:32:32 +02:00

516 lines
15 KiB
JavaScript

/**
* OpenAI Codex SDK Integration
* =============================
*
* This module provides integration with the OpenAI Codex SDK for non-interactive
* chat sessions. It mirrors the pattern used in claude-sdk.js for consistency.
*
* ## Usage
*
* - queryCodex(command, options, ws) - Execute a prompt with streaming via WebSocket
* - abortCodexSession(sessionId) - Cancel an active session
* - isCodexSessionActive(sessionId) - Check if a session is running
* - getActiveCodexSessions() - List all active sessions
*/
import { Codex } from '@openai/codex-sdk';
import { buildCodexInputItems, normalizeImageDescriptors } from './shared/image-attachments.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
const activeCodexSessions = new Map();
function readUsageNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function extractCodexTokenBudget(event) {
const info = event?.info || event?.payload?.info || event?.usage?.info;
const usage = info?.total_token_usage || event?.usage?.total_token_usage || event?.usage;
if (!usage || typeof usage !== 'object') {
return null;
}
const inputTokens = readUsageNumber(usage.input_tokens);
const outputTokens = readUsageNumber(usage.output_tokens);
const used = readUsageNumber(usage.total_tokens) || inputTokens + outputTokens;
return {
used,
total: readUsageNumber(info?.model_context_window || event?.usage?.model_context_window) || 200000,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
/**
* Transform Codex SDK event to WebSocket message format
* @param {object} event - SDK event
* @returns {object} - Transformed event for WebSocket
*/
function transformCodexEvent(event) {
// Map SDK event types to a consistent format
switch (event.type) {
case 'item.started':
case 'item.updated':
case 'item.completed':
const item = event.item;
if (!item) {
return { type: event.type, item: null };
}
// Transform based on item type
switch (item.type) {
case 'agent_message':
return {
type: 'item',
itemType: 'agent_message',
message: {
role: 'assistant',
content: item.text
}
};
case 'reasoning':
return {
type: 'item',
itemType: 'reasoning',
message: {
role: 'assistant',
content: item.text,
isReasoning: true
}
};
case 'command_execution':
return {
type: 'item',
itemType: 'command_execution',
command: item.command,
output: item.aggregated_output,
exitCode: item.exit_code,
status: item.status
};
case 'file_change':
return {
type: 'item',
itemType: 'file_change',
changes: item.changes,
status: item.status
};
case 'mcp_tool_call':
return {
type: 'item',
itemType: 'mcp_tool_call',
server: item.server,
tool: item.tool,
arguments: item.arguments,
result: item.result,
error: item.error,
status: item.status
};
case 'web_search':
return {
type: 'item',
itemType: 'web_search',
query: item.query
};
case 'todo_list':
return {
type: 'item',
itemType: 'todo_list',
items: item.items
};
case 'error':
return {
type: 'item',
itemType: 'error',
message: {
role: 'error',
content: item.message
}
};
default:
return {
type: 'item',
itemType: item.type,
item: item
};
}
case 'turn.started':
return {
type: 'turn_started'
};
case 'turn.completed':
return {
type: 'turn_complete',
usage: event.usage
};
case 'turn.failed':
return {
type: 'turn_failed',
error: event.error
};
case 'thread.started':
return {
type: 'thread_started',
threadId: event.thread_id || event.id
};
case 'error':
return {
type: 'error',
message: event.message
};
default:
return {
type: event.type,
data: event
};
}
}
/**
* Map permission mode to Codex SDK options
* @param {string} permissionMode - 'default', 'acceptEdits', or 'bypassPermissions'
* @returns {object} - { sandboxMode, approvalPolicy }
*/
function mapPermissionModeToCodexOptions(permissionMode) {
switch (permissionMode) {
case 'acceptEdits':
return {
sandboxMode: 'workspace-write',
approvalPolicy: 'never'
};
case 'bypassPermissions':
return {
sandboxMode: 'danger-full-access',
approvalPolicy: 'never'
};
case 'default':
default:
return {
sandboxMode: 'workspace-write',
approvalPolicy: 'untrusted'
};
}
}
/**
* Execute a Codex query with streaming
* @param {string} command - The prompt to send
* @param {object} options - Options including cwd, sessionId, model, permissionMode
* @param {WebSocket|object} ws - WebSocket connection or response writer
*/
export async function queryCodex(command, options = {}, ws) {
const {
sessionId,
sessionSummary,
cwd,
projectPath,
model,
effort,
images,
permissionMode = 'default'
} = options;
const resolvedModel = await providerModelsService.resolveResumeModel(
'codex',
sessionId,
model,
);
const workingDirectory = cwd || projectPath || process.cwd();
const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);
const catalog = (await providerModelsService.getProviderModels('codex')).models;
const selectedModel = catalog.OPTIONS.find((option) => option.value === resolvedModel) || null;
const allowedEfforts = selectedModel?.effort?.values?.map((value) => value.value) || [];
const resolvedEffort = typeof effort === 'string' && effort !== 'default' && allowedEfforts.includes(effort)
? effort
: undefined;
let codex;
let thread;
let capturedSessionId = sessionId;
let sessionCreatedSent = false;
let terminalFailure = null;
const abortController = new AbortController();
try {
codex = new Codex();
const threadOptions = {
workingDirectory,
skipGitRepoCheck: true,
sandboxMode,
approvalPolicy,
model: resolvedModel,
modelReasoningEffort: resolvedEffort,
};
if (sessionId) {
thread = codex.resumeThread(sessionId, threadOptions);
} else {
thread = codex.startThread(threadOptions);
}
const registerSession = (id) => {
if (!id) {
return;
}
activeCodexSessions.set(id, {
thread,
codex,
status: 'running',
abortController,
startedAt: new Date().toISOString()
});
};
if (capturedSessionId) {
registerSession(capturedSessionId);
}
// Execute with streaming. Turns with image attachments send structured
// input items so Codex reads the images from their local asset paths.
const turnInput = normalizeImageDescriptors(images).length > 0
? buildCodexInputItems(command, images, workingDirectory)
: command;
const streamedTurn = await thread.runStreamed(turnInput, {
signal: abortController.signal
});
for await (const event of streamedTurn.events) {
// Capture thread/session id lazily from the stream (Codex emits this asynchronously).
if (event.type === 'thread.started') {
const discoveredSessionId = event.thread_id || event.id || null;
if (discoveredSessionId && !capturedSessionId) {
capturedSessionId = discoveredSessionId;
registerSession(capturedSessionId);
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
sendMessage(ws, createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'codex' }));
}
}
}
// Check if session was aborted
if (abortController.signal.aborted) {
break;
}
if (capturedSessionId) {
const session = activeCodexSessions.get(capturedSessionId);
if (session?.status === 'aborted') {
break;
}
}
if (event.type === 'item.started' || event.type === 'item.updated') {
continue;
}
const transformed = transformCodexEvent(event);
// Normalize the transformed event into NormalizedMessage(s) via adapter
const normalizedMsgs = sessionsService.normalizeMessage('codex', transformed, capturedSessionId || sessionId || null);
for (const msg of normalizedMsgs) {
sendMessage(ws, msg);
}
if (event.type === 'turn.failed' && !terminalFailure) {
terminalFailure = event.error || new Error('Turn failed');
notifyRunFailed({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
error: terminalFailure
});
}
// Extract and send token usage if available (normalized to match Claude format)
if (event.type === 'turn.completed') {
const tokenBudget = extractCodexTokenBudget(event);
if (tokenBudget) {
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
}
}
}
// Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session.
const runSession = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
const runAborted = runSession?.status === 'aborted' || abortController.signal.aborted;
if (!runAborted) {
sendMessage(ws, createCompleteMessage({
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
actualSessionId: capturedSessionId || thread.id || sessionId || null,
exitCode: terminalFailure ? 1 : 0,
}));
if (!terminalFailure) {
notifyRunStopped({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: 'completed'
});
}
}
} catch (error) {
const session = capturedSessionId ? activeCodexSessions.get(capturedSessionId) : null;
const wasAborted =
session?.status === 'aborted' ||
error?.name === 'AbortError' ||
String(error?.message || '').toLowerCase().includes('aborted');
if (!wasAborted) {
console.error('[Codex] Error:', error);
// Check if Codex SDK is available for a clearer error message
const installed = await providerAuthService.isProviderInstalled('codex');
const errorContent = !installed
? 'Codex CLI is not configured. Please set up authentication first.'
: error.message;
sendMessage(ws, createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
sendMessage(ws, createCompleteMessage({
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
exitCode: 1,
}));
if (!terminalFailure) {
notifyRunFailed({
userId: ws?.userId || null,
provider: 'codex',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
error
});
}
}
} finally {
// Update session status
if (capturedSessionId) {
const session = activeCodexSessions.get(capturedSessionId);
if (session) {
session.status = session.status === 'aborted' ? 'aborted' : 'completed';
}
}
}
}
/**
* Abort an active Codex session
* @param {string} sessionId - Session ID to abort
* @returns {boolean} - Whether abort was successful
*/
export function abortCodexSession(sessionId) {
const session = activeCodexSessions.get(sessionId);
if (!session) {
return false;
}
session.status = 'aborted';
try {
session.abortController?.abort();
} catch (error) {
console.warn(`[Codex] Failed to abort session ${sessionId}:`, error);
}
return true;
}
/**
* Check if a session is active
* @param {string} sessionId - Session ID to check
* @returns {boolean} - Whether session is active
*/
export function isCodexSessionActive(sessionId) {
const session = activeCodexSessions.get(sessionId);
return session?.status === 'running';
}
/**
* Get all active sessions
* @returns {Array} - Array of active session info
*/
export function getActiveCodexSessions() {
const sessions = [];
for (const [id, session] of activeCodexSessions.entries()) {
if (session.status === 'running') {
sessions.push({
id,
status: session.status,
startedAt: session.startedAt
});
}
}
return sessions;
}
/**
* Helper to send message via WebSocket or writer
* @param {WebSocket|object} ws - WebSocket or response writer
* @param {object} data - Data to send
*/
function sendMessage(ws, data) {
try {
if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {
// Writer handles stringification (SSEStreamWriter or WebSocketWriter)
ws.send(data);
} else if (typeof ws.send === 'function') {
// Raw WebSocket - stringify here
ws.send(JSON.stringify(data));
}
} catch (error) {
console.error('[Codex] Error sending message:', error);
}
}
// Clean up old completed sessions periodically
setInterval(() => {
const now = Date.now();
const maxAge = 30 * 60 * 1000; // 30 minutes
for (const [id, session] of activeCodexSessions.entries()) {
if (session.status !== 'running') {
const startedAt = new Date(session.startedAt).getTime();
if (now - startedAt > maxAge) {
activeCodexSessions.delete(id);
}
}
}
}, 5 * 60 * 1000); // Every 5 minutes