// Codex 适配器:codex app-server(JSON-RPC)→ 统一事件(docs/design/03-providers.md §3,ADR-0003) import fs from 'node:fs' import path from 'node:path' import type { AppSettings, ProviderStatusDto, UsagePayload } from '@agent-server/shared' import { checkAccess } from '../../services/access-policy' import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types' import { bundledCodexPath, codexHome } from './cli' import { buildThreadConfig } from './permissions' import type { AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountResponse, ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse, PermissionsRequestApprovalParams, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification, ToolRequestUserInputParams, TurnCompletedNotification, TurnStartResponse, } from './protocol/v2' import { AppServerClient, RpcRequestError } from './rpc' const MAX_TOOL_OUTPUT = 64 * 1024 const CLIENT_INFO = { name: 'agent_server', title: 'AgentServer', version: '0.1.0' } interface Logger { info(o: object, m?: string): void; warn(o: object, m?: string): void; error(o: object, m?: string): void } export interface CodexLoginState { loginId: string verificationUrl: string userCode: string status: 'pending' | 'success' | 'failed' | 'cancelled' error: string | null } export class CodexProvider implements AgentProvider { readonly id = 'codex' as const private client: AppServerClient | null = null private starting: Promise | null = null private clientKey = '' private runs = new Map() private models: string[] = [] private login: CodexLoginState | null = null private version: string | null = null constructor(private log: Logger) {} knownModels(): string[] { return this.models } private bin(settings: AppSettings) { return settings.providers.codex.cliPath } /** 启动(或复用)app-server;CLI 路径或配置覆盖变化时重启 */ private async ensure(settings: AppSettings): Promise { const s = settings.providers.codex const key = JSON.stringify([s.cliPath, s.configOverrides]) if (this.client?.alive && this.clientKey === key) return this.client if (this.starting && this.clientKey === key) return this.starting this.client?.stop() this.clientKey = key this.starting = (async () => { const home = codexHome() fs.mkdirSync(home, { recursive: true, mode: 0o700 }) const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: home } delete env.OPENAI_API_KEY delete env.CODEX_API_KEY const client = new AppServerClient({ bin: s.cliPath, env, args: s.configOverrides.flatMap(o => ['-c', o]) }) client.on('notification', (method, params) => this.onNotification(method, params)) client.onServerRequest = (method, params) => this.onServerRequest(method, params) client.on('stderr', line => { if (!/WARN|INFO|DEBUG/.test(line)) this.log.warn({ stderr: line.trim().slice(0, 500) }, 'codex stderr') }) client.on('exit', code => { this.log.warn({ code }, 'codex app-server 已退出') if (this.client === client) this.client = null for (const run of this.runs.values()) run.fail(new ProviderRunError('codex_exited', `codex app-server 意外退出(${code})`)) }) const init = await client.start(CLIENT_INFO) as { userAgent?: string } // userAgent 形如 "agent_server/0.164.0 (...)",斜杠后是 codex 版本 this.version = /\/([\d.]+)/.exec(init.userAgent ?? '')?.[1] ?? null this.client = client return client })() try { return await this.starting } finally { this.starting = null } } async run(ctx: RunContext): Promise { const client = await this.ensure(ctx.settings) fs.mkdirSync(path.join(ctx.workspaceDir, '.agent', 'tmp'), { recursive: true }) const config = buildThreadConfig({ access: ctx.access, permissionMode: ctx.permissionMode, settings: ctx.settings, codexBin: this.bin(ctx.settings), codexHome: codexHome(), }) // 需审批:沙箱内的命令自动执行,越过沙箱边界时请求审批;自动/只读:从不请求,越界直接失败 const approvalPolicy: AskForApproval = ctx.permissionMode === 'ask' ? 'on-request' : 'never' const threadParams = { cwd: ctx.workspaceDir, model: ctx.model, approvalPolicy, config, developerInstructions: ctx.systemPromptAppend, } let threadId = ctx.providerSessionId if (threadId) { try { await client.request('thread/resume', { ...threadParams, threadId, excludeTurns: true }) } catch (err) { ctx.log.warn({ err: (err as Error).message, threadId }, 'codex 会话恢复失败,以新会话继续') threadId = null } } if (!threadId) { const r = await client.request('thread/start', { ...threadParams, serviceName: 'agent-server' }) threadId = r.thread.id ctx.setProviderSessionId(threadId) } const run = new ThreadRun(ctx, threadId) this.runs.set(threadId, run) const onAbort = () => { if (run.turnId) void client.request('turn/interrupt', { threadId, turnId: run.turnId }).catch(() => {}) run.fail(new ProviderRunError('interrupted', '已中断')) } ctx.signal.addEventListener('abort', onAbort, { once: true }) try { const r = await client.request('turn/start', { threadId, model: ctx.model, input: buildInput(ctx) }) run.turnId = r.turn.id return await run.done } finally { ctx.signal.removeEventListener('abort', onAbort) this.runs.delete(threadId) } } private onNotification(method: string, params: unknown) { if (method === 'account/login/completed') { const p = params as { loginId: string | null; success: boolean; error: string | null } if (this.login && (!p.loginId || p.loginId === this.login.loginId)) { Object.assign(this.login, { status: p.success ? 'success' : 'failed', error: p.error }) } return } const threadId = (params as { threadId?: string } | null)?.threadId if (threadId) this.runs.get(threadId)?.onNotification(method, params) } private async onServerRequest(method: string, params: unknown): Promise { const threadId = (params as { threadId?: string } | null)?.threadId const run = threadId ? this.runs.get(threadId) : undefined if (!run) throw new Error(`没有对应的运行:${method}`) return run.onServerRequest(method, params) } // ---- 账户与登录(设备码方式:在 OpenAI 官方页面输入短码完成登录,AgentServer 不接触凭证) ---- async startLogin(settings: AppSettings): Promise { const client = await this.ensure(settings) if (this.login?.status === 'pending') await this.cancelLogin(settings).catch(() => {}) const r = await client.request('account/login/start', { type: 'chatgptDeviceCode' }) if (r.type !== 'chatgptDeviceCode') throw new Error('codex 没有返回设备码') this.login = { loginId: r.loginId, verificationUrl: r.verificationUrl, userCode: r.userCode, status: 'pending', error: null } return this.login } loginState(): CodexLoginState | null { return this.login } async cancelLogin(settings: AppSettings) { if (!this.login || this.login.status !== 'pending') return const client = await this.ensure(settings) await client.request('account/login/cancel', { loginId: this.login.loginId }).catch(() => {}) this.login.status = 'cancelled' } async logout(settings: AppSettings) { const client = await this.ensure(settings) await client.request('account/logout', {}) } async status(settings: AppSettings): Promise { const s = settings.providers.codex const base: ProviderStatusDto = { id: 'codex', enabled: s.enabled, available: false, loggedIn: null, account: null, cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null, } try { const client = await this.ensure(settings) const acc = await client.request('account/read', {}, 30_000) const loggedIn = !!acc.account let models = s.models if (loggedIn) { const list = await client.request('model/list', {}, 30_000).catch(() => null) if (list) { this.models = list.data.filter(m => !m.hidden).map(m => m.model) if (!models.length) models = this.models } } const account = acc.account?.type === 'chatgpt' ? [acc.account.email, `(${acc.account.planType})`].filter(Boolean).join(' ') : acc.account?.type ?? null return { ...base, version: this.version, loggedIn, account, models, defaultModel: s.defaultModel || models[0] || '', available: s.enabled && loggedIn, message: loggedIn ? (s.enabled ? null : '已登录,尚未在"设置 → 引擎"中启用') : '未登录:点击"登录"按钮,用 ChatGPT 账号完成设备码登录', } } catch (err) { return { ...base, message: `无法启动 codex app-server:${(err as Error).message}` } } } } export function defaultCodexPath() { return bundledCodexPath() } function buildInput(ctx: RunContext) { let text = ctx.input.text if (ctx.input.attachments.length) { const list = ctx.input.attachments.map(a => `- ${a.path}(${a.mime})`).join('\n') text += `\n\n\n用户上传了以下文件,位于工作区中:\n${list}\n` } const images = ctx.input.attachments .filter(a => ctx.input.images.some(i => i.name === a.name)) .map(a => ({ type: 'localImage' as const, path: path.join(ctx.workspaceDir, a.path) })) return [{ type: 'text' as const, text, text_elements: [] }, ...images] } /** 一次 Run 对应的一个 turn:把通知映射为统一事件,处理审批与提问 */ class ThreadRun { turnId: string | null = null readonly done: Promise private resolve!: (r: RunResult) => void private reject!: (e: Error) => void private settled = false private items = new Map() private denied = new Set() private usageBase: UsagePayload | null = null private usageLatest: UsagePayload | null = null constructor(private ctx: RunContext, private threadId: string) { this.done = new Promise((res, rej) => { this.resolve = res this.reject = rej }) } fail(err: Error) { if (this.settled) return this.settled = true this.reject(err) } onNotification(method: string, params: unknown) { const emit = this.ctx.emit switch (method) { case 'item/started': { const { item } = params as ItemStartedNotification this.items.set(item.id, item) const tool = describeTool(item, this.ctx.workspaceDir) if (tool) emit('tool.started', { toolCallId: item.id, ...tool }) if (item.type === 'commandExecution') this.ctx.audit('command', item.command.slice(0, 500), { itemId: item.id }) break } case 'item/agentMessage/delta': { const p = params as { itemId: string; delta: string } emit('message.delta', { messageId: p.itemId, text: p.delta }) break } case 'item/reasoning/summaryTextDelta': { const p = params as { itemId: string; delta: string } emit('reasoning.delta', { blockId: p.itemId, text: p.delta }) break } case 'item/completed': { const { item } = params as ItemCompletedNotification this.items.set(item.id, item) this.onItemCompleted(item) break } case 'thread/tokenUsage/updated': { const { tokenUsage } = params as ThreadTokenUsageUpdatedNotification const toUsage = (b: typeof tokenUsage.total): UsagePayload => ({ inputTokens: b.inputTokens - b.cachedInputTokens, outputTokens: b.outputTokens, cacheReadTokens: b.cachedInputTokens, cacheWriteTokens: b.cacheWriteInputTokens, }) // 本次 Run 的用量 = 最新累计 −(第一次通知时的累计 − 那一次的增量) if (!this.usageBase) { const t = toUsage(tokenUsage.total), l = toUsage(tokenUsage.last) this.usageBase = { inputTokens: t.inputTokens - l.inputTokens, outputTokens: t.outputTokens - l.outputTokens, cacheReadTokens: t.cacheReadTokens - l.cacheReadTokens, cacheWriteTokens: t.cacheWriteTokens - l.cacheWriteTokens, } } this.usageLatest = toUsage(tokenUsage.total) break } case 'turn/completed': { const { turn } = params as TurnCompletedNotification if (this.settled) return if (turn.status === 'completed') { this.settled = true this.resolve({ usage: this.runUsage() }) } else if (turn.status === 'interrupted') { this.fail(new ProviderRunError('interrupted', '已中断')) } else { this.fail(new ProviderRunError('codex_failed', turn.error?.message ?? '运行失败')) } break } case 'error': { const p = params as { error: { message: string }; willRetry: boolean } this.ctx.log.warn({ error: p.error.message, willRetry: p.willRetry }, 'codex error') break } } } private runUsage(): UsagePayload | null { if (!this.usageLatest || !this.usageBase) return null const b = this.usageBase, l = this.usageLatest return { inputTokens: l.inputTokens - b.inputTokens, outputTokens: l.outputTokens - b.outputTokens, cacheReadTokens: l.cacheReadTokens - b.cacheReadTokens, cacheWriteTokens: l.cacheWriteTokens - b.cacheWriteTokens, } } private onItemCompleted(item: ThreadItem) { const emit = this.ctx.emit const done = (status: 'ok' | 'error' | 'denied', output: string) => emit('tool.completed', { toolCallId: item.id, status: this.denied.has(item.id) ? 'denied' : status, output: output.slice(0, MAX_TOOL_OUTPUT), truncated: output.length > MAX_TOOL_OUTPUT, }) switch (item.type) { case 'agentMessage': if (item.text) emit('message.completed', { messageId: item.id, text: item.text }) break case 'reasoning': { const text = item.summary.join('\n').trim() if (text) emit('reasoning.completed', { blockId: item.id, text }) break } case 'commandExecution': done(item.status === 'declined' ? 'denied' : item.status === 'completed' && (item.exitCode ?? 0) === 0 ? 'ok' : 'error', `${item.aggregatedOutput ?? ''}${item.exitCode ? `\n(exit ${item.exitCode})` : ''}`.trim()) break case 'fileChange': done(item.status === 'declined' ? 'denied' : item.status === 'completed' ? 'ok' : 'error', item.changes.map(c => `${c.kind} ${c.path}`).join('\n')) break case 'mcpToolCall': done(item.status === 'completed' ? 'ok' : 'error', item.error ? JSON.stringify(item.error) : (item.result?.content ?? []).map(c => (c as { text?: string }).text ?? '').join('\n')) break case 'webSearch': case 'imageView': done('ok', '') break } } async onServerRequest(method: string, params: unknown): Promise { const ctx = this.ctx switch (method) { case 'item/commandExecution/requestApproval': { const p = params as CommandExecutionRequestApprovalParams // 网络访问不放开:Codex 的命令一旦联网就是无限制的(可访问内网) if (p.networkApprovalContext) { this.denied.add(p.itemId) ctx.audit('network_denied', `拒绝命令联网:${p.command ?? ''}`.slice(0, 500), { networkApprovalContext: p.networkApprovalContext }) return { decision: 'decline' } } if (ctx.permissionMode === 'readonly') return { decision: 'decline' } const command = p.command ?? (this.items.get(p.itemId) as { command?: string } | undefined)?.command ?? '' const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: commandTitle(command) || '执行命令', input: { command, cwd: p.cwd }, detail: { command, reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'], }) ctx.audit('approval', `codex 命令:${outcome.resolution}`, { command }) if (outcome.resolution === 'allowed') return { decision: outcome.scope === 'session' ? 'acceptForSession' : 'accept' } this.denied.add(p.itemId) return { decision: outcome.resolution === 'denied' ? 'decline' : 'cancel' } } case 'item/fileChange/requestApproval': { const p = params as FileChangeRequestApprovalParams const item = this.items.get(p.itemId) const paths = item?.type === 'fileChange' ? item.changes.map(c => path.resolve(ctx.workspaceDir, c.path)) : [] if (p.grantRoot) paths.push(path.resolve(ctx.workspaceDir, p.grantRoot)) const blocked = paths.map(f => ({ f, d: checkAccess(ctx.access, f, 'write') })).find(x => !x.d.allowed) if (ctx.permissionMode === 'readonly' || blocked || !paths.length) { this.denied.add(p.itemId) if (blocked && !blocked.d.allowed) ctx.audit('file_access_denied', blocked.d.reason, { tool: 'codex fileChange', paths }) return { decision: 'decline' } } const outcome = await ctx.interact({ kind: 'approval', tool: 'Edit', title: paths.map(f => path.relative(ctx.workspaceDir, f)).join('、'), input: { paths }, detail: { path: paths[0], reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'], }) if (outcome.resolution === 'allowed') return { decision: outcome.scope === 'session' ? 'acceptForSession' : 'accept' } this.denied.add(p.itemId) return { decision: outcome.resolution === 'denied' ? 'decline' : 'cancel' } } case 'item/permissions/requestApproval': { // 申请额外权限:联网一律拒绝;文件路径必须在 Profile 的访问策略之内,然后由人审批 const p = params as PermissionsRequestApprovalParams const reqFs = p.permissions.fileSystem const reads = (reqFs?.read ?? []).map(f => path.resolve(p.cwd, f)) const writes = (reqFs?.write ?? []).map(f => path.resolve(p.cwd, f)) const outside = [...reads.map(f => checkAccess(ctx.access, f, 'read')), ...writes.map(f => checkAccess(ctx.access, f, 'write'))].find(d => !d.allowed) const wantsNetwork = !!p.permissions.network?.enabled if (wantsNetwork) ctx.audit('network_denied', '拒绝 codex 申请联网权限', { reason: p.reason }) if (outside && !outside.allowed) ctx.audit('file_access_denied', outside.reason, { reads, writes }) if (wantsNetwork || outside || (!reads.length && !writes.length) || ctx.permissionMode !== 'ask') { return { permissions: {}, scope: 'turn' } } const outcome = await ctx.interact({ kind: 'approval', tool: 'Permissions', title: `申请访问:${[...reads, ...writes].join('、')}`.slice(0, 300), input: p.permissions, detail: { reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'], }) if (outcome.resolution !== 'allowed') return { permissions: {}, scope: 'turn' } return { permissions: { fileSystem: { read: reads, write: writes } }, scope: outcome.scope === 'session' ? 'session' : 'turn' } } case 'item/tool/requestUserInput': { const p = params as ToolRequestUserInputParams const outcome = await ctx.interact({ kind: 'question', questions: p.questions.map(q => ({ id: q.id, question: q.question, header: q.header, multiSelect: false, options: (q.options ?? []).map(o => ({ label: o.label, description: o.description })), })), }) const answers: Record = {} if (outcome.resolution === 'answered') { for (const q of p.questions) { const a = outcome.answers[q.id] if (a !== undefined) answers[q.id] = { answers: Array.isArray(a) ? a : [a] } } } return { answers } } case 'mcpServer/elicitation/request': return { action: 'decline', content: null, _meta: null } default: throw new RpcRequestError(method, { code: -32601, message: `AgentServer 不支持:${method}` }) } } } /** Codex 把命令包成 `/usr/bin/bash -lc "..."`,显示时去掉外层,只保留第一行 */ export function commandTitle(command: string): string { let c = command.trim() const m = /^(?:\/usr)?(?:\/bin\/)?(?:ba|z)?sh\s+-l?c\s+([\s\S]*)$/.exec(c) if (m) { c = m[1]!.trim() if ((c.startsWith('"') && c.endsWith('"')) || (c.startsWith("'") && c.endsWith("'"))) c = c.slice(1, -1) } return c.split('\n')[0]!.slice(0, 200) } function describeTool(item: ThreadItem, cwd: string): { name: string; title: string; input: unknown } | null { const rel = (p: string) => { const r = path.relative(cwd, path.resolve(cwd, p)) return r.startsWith('..') ? p : r } switch (item.type) { case 'commandExecution': return { name: 'Bash', title: commandTitle(item.command), input: { command: item.command, cwd: item.cwd } } case 'fileChange': return { name: 'Edit', title: item.changes.map(c => rel(c.path)).join('、').slice(0, 200), input: { changes: item.changes.map(c => ({ path: c.path, kind: c.kind, diff: c.diff })) } } case 'mcpToolCall': return { name: `mcp__${item.server}__${item.tool}`, title: '', input: item.arguments } case 'webSearch': return { name: 'WebSearch', title: (item as { query?: string }).query ?? '', input: { query: (item as { query?: string }).query } } case 'imageView': return { name: 'Read', title: rel(item.path), input: { path: item.path } } default: return null } }