Files
rokcet-dev/server/services/codexAppServerAdapter.ts
Varin Narula e7b8980b98
Some checks failed
Publish Phase 3 workspace images / Publish agent image (push) Failing after 20s
Publish Phase 3 workspace images / Publish workspace-web image (push) Failing after 23s
Publish Phase 3 workspace images / Publish workspace-host image (push) Failing after 19s
feat: add ChatGPT OAuth agent boundary
2026-09-20 02:55:33 +07:00

396 lines
19 KiB
TypeScript

import crypto from 'node:crypto';
import type { ServerNotification } from '../generated/codex/0.154.0-alpha.6.2/ts/ServerNotification';
import type { ServerRequest } from '../generated/codex/0.154.0-alpha.6.2/ts/ServerRequest';
import type { GetAccountResponse } from '../generated/codex/0.154.0-alpha.6.2/ts/v2/GetAccountResponse';
import type { LoginAccountResponse } from '../generated/codex/0.154.0-alpha.6.2/ts/v2/LoginAccountResponse';
import type {
CodingAgentAdapter,
CodingAgentAdapterSession,
CodingAgentAdapterTurn,
CodingAgentApprovalDecision,
CodingAgentEventHandler,
CodingAgentStartSessionInput,
CodingAgentStartTurnInput,
} from '../../shared/codingAgentEvents';
import { CodexProcessManager, type CodexProcessManagerOptions } from './codexProcessManager';
export const CODEX_APP_SERVER_VERSION = '0.154.0-alpha.6.2';
export const CODEX_APP_SERVER_SCHEMA_DIGEST = '20de09a3e153de373a5b6a6270605af4e05e2f4a0cbb3af2e4a548120a4e3007';
type PendingApproval = {
method: ServerRequest['method'];
rpcId: string | number;
codingSessionId: string;
turnId: string;
requestedPermissions?: Record<string, unknown>;
};
const IGNORED_NOTIFICATIONS = new Set([
'thread/started', 'thread/status/changed', 'serverRequest/resolved',
'account/updated', 'account/rateLimits/updated', 'model/verification', 'model/rerouted',
'warning', 'guardianWarning', 'configWarning', 'deprecationNotice',
'remoteControl/status/changed', 'app/list/updated', 'skills/changed', 'project/changed',
'mcpServer/startupStatus/updated', 'modelProvider/authRecoveryStarted',
'modelProvider/authRecoveryCompleted', 'model/safetyBuffering/updated',
'turn/moderationMetadata', 'fs/changed', 'thread/tokenUsage/updated',
'item/reasoning/summaryTextDelta', 'item/reasoning/summaryPartAdded', 'item/reasoning/textDelta',
]);
const MAX_COMMAND_OUTPUT_CHARS_PER_TURN = 32_000;
function diffSummary(diff: string): Record<string, unknown> {
return {
byteSize: Buffer.byteLength(diff, 'utf8'),
lineCount: diff ? diff.split('\n').length : 0,
digest: crypto.createHash('sha256').update(diff).digest('hex'),
};
}
function safeItemPayload(item: any): Record<string, unknown> {
const payload: Record<string, unknown> = { type: String(item.type), status: String(item.status || '') };
if (item.type === 'commandExecution') payload.command = item.command;
if (item.type === 'fileChange') payload.changeCount = Array.isArray(item.changes) ? item.changes.length : 0;
if (item.type === 'mcpToolCall' || item.type === 'dynamicToolCall') {
payload.tool = item.tool || item.name || null;
payload.server = item.server || item.serverName || null;
}
return payload;
}
export class CodexAppServerAdapter implements CodingAgentAdapter {
readonly key = 'codex-app-server';
private readonly manager: CodexProcessManager;
private readonly handlers = new Map<string, CodingAgentEventHandler>();
private readonly sessionByThread = new Map<string, string>();
private readonly localTurnByProviderTurn = new Map<string, string>();
private readonly pendingLocalTurnByThread = new Map<string, string>();
private readonly activeTurnBySession = new Map<string, string>();
private readonly approvals = new Map<string, PendingApproval>();
private readonly localItemByProviderItem = new Map<string, string>();
private readonly commandOutputCharsByTurn = new Map<string, number>();
constructor(options: Omit<CodexProcessManagerOptions, 'expectedVersion'> = {}) {
this.manager = new CodexProcessManager({ ...options, expectedVersion: CODEX_APP_SERVER_VERSION });
this.manager.onNotification((message) => this.handleNotification(message as ServerNotification));
this.manager.onServerRequest((message) => this.handleServerRequest(message as ServerRequest));
this.manager.onExit((error) => {
for (const [codingSessionId, handler] of this.handlers) {
const turnId = this.activeTurnBySession.get(codingSessionId);
if (turnId) void handler({
kind: 'turn.recovery_required', turnId,
payload: { code: 'CODEX_PROCESS_EXITED', message: error.message },
});
this.handlers.delete(codingSessionId);
}
this.activeTurnBySession.clear();
});
}
async health() {
try {
await this.manager.start();
return {
healthy: true, adapterKey: this.key, adapterVersion: CODEX_APP_SERVER_VERSION,
schemaDigest: CODEX_APP_SERVER_SCHEMA_DIGEST,
};
} catch (error: any) {
return {
healthy: false, adapterKey: this.key, adapterVersion: CODEX_APP_SERVER_VERSION,
schemaDigest: CODEX_APP_SERVER_SCHEMA_DIGEST, detail: error?.message || 'Codex App Server is unavailable',
};
}
}
async readAccount(refreshToken = false): Promise<GetAccountResponse> {
await this.manager.start();
return this.manager.request('account/read', { refreshToken });
}
async startChatGptLogin(): Promise<LoginAccountResponse> {
await this.manager.start();
return this.manager.request('account/login/start', { type: 'chatgptDeviceCode' });
}
async logoutAccount(): Promise<void> {
await this.manager.start();
await this.manager.request('account/logout', {});
}
async startSession(input: CodingAgentStartSessionInput, onEvent: CodingAgentEventHandler): Promise<CodingAgentAdapterSession> {
await this.manager.start();
const response: any = await this.manager.request('thread/start', {
cwd: input.workspacePath,
approvalPolicy: 'on-request',
serviceName: 'rokcet',
ephemeral: false,
});
const providerThreadId = String(response?.thread?.id || '');
if (!providerThreadId) throw new Error('Codex App Server thread/start returned no thread id');
this.handlers.set(input.codingSessionId, onEvent);
this.sessionByThread.set(providerThreadId, input.codingSessionId);
return { providerThreadId, providerSessionId: response?.thread?.sessionId || null };
}
async resumeSession(providerThreadId: string, input: CodingAgentStartSessionInput, onEvent: CodingAgentEventHandler): Promise<CodingAgentAdapterSession> {
await this.manager.start();
const response: any = await this.manager.request('thread/resume', {
threadId: providerThreadId,
cwd: input.workspacePath,
approvalPolicy: 'on-request',
excludeTurns: true,
});
const resumedThreadId = String(response?.thread?.id || '');
if (resumedThreadId !== providerThreadId) throw new Error('Codex App Server resumed an unexpected thread');
this.handlers.set(input.codingSessionId, onEvent);
this.sessionByThread.set(providerThreadId, input.codingSessionId);
return { providerThreadId, providerSessionId: response?.thread?.sessionId || null };
}
async startTurn(providerThreadId: string, input: CodingAgentStartTurnInput): Promise<CodingAgentAdapterTurn> {
this.pendingLocalTurnByThread.set(providerThreadId, input.turnId);
this.activeTurnBySession.set(input.codingSessionId, input.turnId);
try {
const response: any = await this.manager.request('turn/start', {
threadId: providerThreadId,
input: [{ type: 'text', text: input.prompt }],
cwd: input.workspacePath,
approvalPolicy: 'on-request',
sandboxPolicy: {
type: 'workspaceWrite', writableRoots: [input.workspacePath], networkAccess: false,
excludeTmpdirEnvVar: false, excludeSlashTmp: false,
},
});
const providerTurnId = String(response?.turn?.id || '');
if (!providerTurnId) throw new Error('Codex App Server turn/start returned no turn id');
this.localTurnByProviderTurn.set(providerTurnId, input.turnId);
return { providerTurnId };
} catch (error) {
this.activeTurnBySession.delete(input.codingSessionId);
throw error;
} finally {
this.pendingLocalTurnByThread.delete(providerThreadId);
}
}
async interruptTurn(providerThreadId: string, providerTurnId: string): Promise<void> {
await this.manager.request('turn/interrupt', { threadId: providerThreadId, turnId: providerTurnId });
}
async startReview(providerThreadId: string, target: { type: 'uncommitted_changes' | 'commit'; sha?: string }, turnId: string): Promise<CodingAgentAdapterTurn> {
const codingSessionId = this.sessionByThread.get(providerThreadId);
if (!codingSessionId) throw new Error('Provider thread is not attached to a Rokcet session');
this.pendingLocalTurnByThread.set(providerThreadId, turnId);
this.activeTurnBySession.set(codingSessionId, turnId);
try {
const response: any = await this.manager.request('review/start', {
threadId: providerThreadId,
delivery: 'inline',
target: target.type === 'commit'
? { type: 'commit', sha: target.sha, title: null }
: { type: 'uncommittedChanges' },
});
const providerTurnId = String(response?.turn?.id || '');
if (!providerTurnId) throw new Error('Codex App Server review/start returned no turn id');
this.localTurnByProviderTurn.set(providerTurnId, turnId);
return { providerTurnId };
} catch (error) {
this.activeTurnBySession.delete(codingSessionId);
throw error;
} finally {
this.pendingLocalTurnByThread.delete(providerThreadId);
}
}
async resolveApproval(providerRequestId: string, decision: CodingAgentApprovalDecision, details: Record<string, unknown> = {}): Promise<void> {
const pending = this.approvals.get(providerRequestId);
if (!pending) throw new Error('Codex approval request is no longer pending');
if (pending.method === 'item/commandExecution/requestApproval' || pending.method === 'item/fileChange/requestApproval') {
const codexDecision = decision === 'accept_for_session' ? 'acceptForSession' : decision;
this.manager.respond(pending.rpcId, { decision: codexDecision });
} else if (pending.method === 'item/permissions/requestApproval') {
this.manager.respond(pending.rpcId, {
permissions: decision === 'accept' || decision === 'accept_for_session' ? (pending.requestedPermissions || {}) : {},
...(decision === 'accept_for_session' ? { scope: 'session' } : { scope: 'turn' }),
});
} else if (pending.method === 'item/tool/requestUserInput') {
this.manager.respond(pending.rpcId, { answers: decision === 'accept' ? (details.answers || {}) : {} });
} else if (pending.method === 'mcpServer/elicitation/request') {
this.manager.respond(pending.rpcId, {
action: decision === 'accept' ? 'accept' : decision === 'cancel' ? 'cancel' : 'decline',
content: decision === 'accept' ? (details.content || {}) : null,
});
} else {
throw new Error(`Unsupported Codex approval method: ${pending.method}`);
}
this.approvals.delete(providerRequestId);
}
async shutdown(): Promise<void> {
await this.manager.stop();
this.handlers.clear();
this.sessionByThread.clear();
this.localTurnByProviderTurn.clear();
this.pendingLocalTurnByThread.clear();
this.activeTurnBySession.clear();
this.approvals.clear();
this.localItemByProviderItem.clear();
this.commandOutputCharsByTurn.clear();
}
private localTurn(threadId: string, providerTurnId: string): string | null {
return this.localTurnByProviderTurn.get(providerTurnId) || this.pendingLocalTurnByThread.get(threadId) || null;
}
private handlerForThread(threadId: string): { codingSessionId: string; handler: CodingAgentEventHandler } | null {
const codingSessionId = this.sessionByThread.get(threadId);
const handler = codingSessionId ? this.handlers.get(codingSessionId) : null;
return codingSessionId && handler ? { codingSessionId, handler } : null;
}
private localItem(threadId: string, providerItemId: unknown): string | null {
const providerId = typeof providerItemId === 'string' ? providerItemId : '';
if (!providerId) return null;
const key = `${threadId}:${providerId}`;
const existing = this.localItemByProviderItem.get(key);
if (existing) return existing;
const localId = crypto.randomUUID();
this.localItemByProviderItem.set(key, localId);
return localId;
}
private async handleNotification(message: ServerNotification): Promise<void> {
const params: any = message.params || {};
const scoped = typeof params.threadId === 'string' ? this.handlerForThread(params.threadId) : null;
if (message.method === 'turn/started') {
if (!scoped) return;
const providerTurnId = String(params.turn?.id || '');
const turnId = this.localTurn(params.threadId, providerTurnId);
if (!turnId) throw new Error('Codex started an unmapped turn');
this.localTurnByProviderTurn.set(providerTurnId, turnId);
await scoped.handler({ kind: 'turn.started', turnId, payload: {} });
return;
}
if (message.method === 'turn/completed') {
if (!scoped) return;
const providerTurnId = String(params.turn?.id || '');
const turnId = this.localTurn(params.threadId, providerTurnId);
if (!turnId) throw new Error('Codex completed an unmapped turn');
const status = String(params.turn?.status || 'failed');
this.activeTurnBySession.delete(scoped.codingSessionId);
this.commandOutputCharsByTurn.delete(turnId);
await scoped.handler({
kind: status === 'interrupted' ? 'turn.interrupted' : status === 'failed' ? 'turn.failed' : 'turn.completed',
turnId,
payload: status === 'failed'
? { code: 'CODEX_TURN_FAILED', message: String(params.turn?.error?.message || 'Codex turn failed') }
: { status },
});
return;
}
if (message.method === 'item/agentMessage/delta') {
if (!scoped) return;
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
if (!turnId) throw new Error('Codex emitted a delta for an unmapped turn');
await scoped.handler({ kind: 'assistant.delta', turnId, itemId: this.localItem(params.threadId, params.itemId), payload: { delta: String(params.delta || '') } });
return;
}
if (message.method === 'item/commandExecution/outputDelta') {
if (!scoped) return;
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
if (!turnId) throw new Error('Codex emitted command output for an unmapped turn');
const delta = String(params.delta || '');
const used = this.commandOutputCharsByTurn.get(turnId) || 0;
const remaining = Math.max(0, MAX_COMMAND_OUTPUT_CHARS_PER_TURN - used);
if (remaining > 0) {
const bounded = delta.slice(0, remaining);
this.commandOutputCharsByTurn.set(turnId, used + bounded.length);
await scoped.handler({ kind: 'command.output', turnId, itemId: this.localItem(params.threadId, params.itemId), payload: { delta: bounded } });
}
if (delta.length > remaining && used <= MAX_COMMAND_OUTPUT_CHARS_PER_TURN) {
this.commandOutputCharsByTurn.set(turnId, MAX_COMMAND_OUTPUT_CHARS_PER_TURN + 1);
await scoped.handler({ kind: 'stream.truncated', turnId, itemId: this.localItem(params.threadId, params.itemId), payload: { stream: 'command.output', limitChars: MAX_COMMAND_OUTPUT_CHARS_PER_TURN } });
}
return;
}
if (message.method === 'turn/diff/updated') {
if (!scoped) return;
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
if (!turnId) throw new Error('Codex emitted a diff for an unmapped turn');
await scoped.handler({ kind: 'diff.updated', turnId, payload: diffSummary(String(params.diff || '')) });
return;
}
if (message.method === 'turn/plan/updated') {
if (!scoped) return;
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
if (turnId) await scoped.handler({ kind: 'plan.updated', turnId, payload: { plan: params.plan || [] } });
return;
}
if (message.method === 'item/started' || message.method === 'item/completed') {
if (!scoped) return;
const item = params.item as any;
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
if (!turnId || !item?.type || !item?.id) throw new Error('Codex emitted a malformed item lifecycle');
const suffix = message.method === 'item/started' ? 'started' : 'completed';
const kind = item.type === 'commandExecution' ? `command.${suffix}`
: item.type === 'fileChange' ? `file_change.${suffix}`
: item.type === 'mcpToolCall' || item.type === 'dynamicToolCall' ? `tool.${suffix}`
: item.type === 'agentMessage' && suffix === 'completed' ? 'assistant.completed'
: null;
if (kind) await scoped.handler({
kind: kind as any, turnId,
itemId: this.localItem(params.threadId, item.id),
payload: safeItemPayload(item),
});
return;
}
if (message.method === 'error') {
if (!scoped) return;
const providerTurnId = String(params.turnId || '');
const turnId = this.localTurn(params.threadId, providerTurnId);
if (turnId) await scoped.handler({ kind: 'turn.failed', turnId, payload: { code: 'CODEX_ERROR', message: String(params.error?.message || 'Codex error') } });
return;
}
if (IGNORED_NOTIFICATIONS.has(message.method)) return;
throw new Error(`Unsupported Codex notification: ${(message as any).method}`);
}
private async handleServerRequest(message: ServerRequest): Promise<void> {
const params: any = message.params || {};
const scoped = this.handlerForThread(String(params.threadId || ''));
if (!scoped) throw new Error('Codex approval request is not scoped to a Rokcet session');
const turnId = this.localTurn(String(params.threadId || ''), String(params.turnId || ''));
if (!turnId) throw new Error('Codex approval request is not scoped to a Rokcet turn');
if (![
'item/commandExecution/requestApproval', 'item/fileChange/requestApproval',
'item/tool/requestUserInput', 'mcpServer/elicitation/request', 'item/permissions/requestApproval',
].includes(message.method)) throw new Error(`Unsupported Codex server request: ${message.method}`);
const providerRequestId = String(message.id);
this.approvals.set(providerRequestId, {
method: message.method, rpcId: message.id,
codingSessionId: scoped.codingSessionId, turnId,
...(params.permissions && typeof params.permissions === 'object' && !Array.isArray(params.permissions)
? { requestedPermissions: params.permissions as Record<string, unknown> }
: {}),
});
const kind = message.method === 'item/fileChange/requestApproval' ? 'file_change'
: message.method === 'item/permissions/requestApproval' ? 'permission'
: message.method === 'item/tool/requestUserInput' ? 'user_input'
: message.method === 'mcpServer/elicitation/request' ? 'mcp_form'
: params.networkApprovalContext ? 'network' : 'command';
const payload: Record<string, unknown> = {
providerRequestId, kind,
allowedDecisions: ['accept', 'accept_for_session', 'decline', 'cancel'],
};
for (const key of ['reason', 'command', 'networkApprovalContext', 'questions', 'message', 'requestedSchema'] as const) {
if (params[key] !== undefined) payload[key] = params[key];
}
if (params.permissions && typeof params.permissions === 'object' && !Array.isArray(params.permissions)) {
payload.permissionKinds = Object.keys(params.permissions);
}
await scoped.handler({
kind: 'approval.requested', turnId,
itemId: this.localItem(String(params.threadId || ''), params.itemId),
payload,
});
}
}