adapter.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. // Codex 适配器:codex app-server(JSON-RPC)→ 统一事件(docs/design/03-providers.md §3,ADR-0003)
  2. import { createHash } from 'node:crypto'
  3. import fs from 'node:fs'
  4. import path from 'node:path'
  5. import type { AppSettings, ProviderQuotaDto, ProviderStatusDto, QuotaWindowDto, UsagePayload } from '@agent-server/shared'
  6. import { communitySkillsDir, customSkillsDir } from '../../config'
  7. import { checkAccess } from '../../services/access-policy'
  8. import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
  9. import { bundledCodexPath, codexHome } from './cli'
  10. import { GATEWAY_SERVER, gatewayToolTimeoutSec } from '../../mcp-gateway/server'
  11. import { buildThreadConfig } from './permissions'
  12. import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills'
  13. import type {
  14. AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountRateLimitsResponse, GetAccountResponse, RateLimitSnapshot,
  15. ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse, SkillMetadata, SkillsListResponse,
  16. McpServerElicitationRequestParams, PermissionsRequestApprovalParams, ThreadForkResponse, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification,
  17. ToolRequestUserInputParams, TurnCompletedNotification, TurnStartResponse,
  18. } from './protocol/v2'
  19. import { AppServerClient, RpcRequestError } from './rpc'
  20. const MAX_TOOL_OUTPUT = 64 * 1024
  21. const CLIENT_INFO = { name: 'agent_server', title: 'AgentServer', version: '0.1.0' }
  22. interface Logger { info(o: object, m?: string): void; warn(o: object, m?: string): void; error(o: object, m?: string): void }
  23. export interface CodexLoginState {
  24. loginId: string
  25. verificationUrl: string
  26. userCode: string
  27. status: 'pending' | 'success' | 'failed' | 'cancelled'
  28. error: string | null
  29. }
  30. export class CodexProvider implements AgentProvider {
  31. readonly id = 'codex' as const
  32. private client: AppServerClient | null = null
  33. private starting: Promise<AppServerClient> | null = null
  34. private clientKey = ''
  35. private runs = new Map<string, ThreadRun>()
  36. private quotaCache: { at: number; value: ProviderQuotaDto } | null = null
  37. /**
  38. * 当前 app-server 进程中已加载的会话及其加载时的配置指纹。已加载会话的配置不会被 thread/resume 更新
  39. * (已验证,codex 0.164),配置变化时改为 thread/fork:保留历史、采用新配置(ADR-0020)
  40. */
  41. private loaded = new Map<string, string>()
  42. private models: string[] = []
  43. private login: CodexLoginState | null = null
  44. private version: string | null = null
  45. constructor(private log: Logger) {}
  46. knownModels(): string[] {
  47. return this.models
  48. }
  49. private bin(settings: AppSettings) {
  50. return settings.providers.codex.cliPath
  51. }
  52. /** 启动(或复用)app-server;CLI 路径或配置覆盖变化时重启 */
  53. private async ensure(settings: AppSettings): Promise<AppServerClient> {
  54. const s = settings.providers.codex
  55. const key = JSON.stringify([s.cliPath, s.configOverrides])
  56. if (this.client?.alive && this.clientKey === key) return this.client
  57. if (this.starting && this.clientKey === key) return this.starting
  58. this.client?.stop()
  59. this.clientKey = key
  60. this.starting = (async () => {
  61. const home = codexHome()
  62. fs.mkdirSync(home, { recursive: true, mode: 0o700 })
  63. const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: home }
  64. delete env.OPENAI_API_KEY
  65. delete env.CODEX_API_KEY
  66. const client = new AppServerClient({ bin: s.cliPath, env, args: s.configOverrides.flatMap(o => ['-c', o]) })
  67. client.on('notification', (method, params) => this.onNotification(method, params))
  68. client.onServerRequest = (method, params) => this.onServerRequest(method, params)
  69. client.on('stderr', line => {
  70. if (!/WARN|INFO|DEBUG/.test(line)) this.log.warn({ stderr: line.trim().slice(0, 500) }, 'codex stderr')
  71. })
  72. client.on('exit', code => {
  73. this.log.warn({ code }, 'codex app-server 已退出')
  74. this.loaded.clear()
  75. if (this.client === client) this.client = null
  76. for (const run of this.runs.values()) run.fail(new ProviderRunError('codex_exited', `codex app-server 意外退出(${code})`))
  77. })
  78. const init = await client.start(CLIENT_INFO) as { userAgent?: string }
  79. // 本地技能目录(自写技能)与第三方技能目录与 Claude 共用,作为额外技能根目录
  80. // 自定义技能目录在服务启动时创建,之后发布的技能 Codex 每次列技能时都会看到
  81. const extraRoots = [path.join(settings.paths.skillsDir, 'skills'), path.join(communitySkillsDir(), 'skills'), path.join(customSkillsDir(), 'skills')].filter(d => fs.existsSync(d))
  82. if (extraRoots.length) await client.request('skills/extraRoots/set', { extraRoots }).catch(err => this.log.warn({ err: (err as Error).message }, 'codex extraRoots 设置失败'))
  83. // userAgent 形如 "agent_server/0.164.0 (...)",斜杠后是 codex 版本
  84. this.version = /\/([\d.]+)/.exec(init.userAgent ?? '')?.[1] ?? null
  85. this.client = client
  86. return client
  87. })()
  88. try {
  89. return await this.starting
  90. } finally {
  91. this.starting = null
  92. }
  93. }
  94. async run(ctx: RunContext): Promise<RunResult> {
  95. const client = await this.ensure(ctx.settings)
  96. fs.mkdirSync(path.join(ctx.workspaceDir, '.agent', 'tmp'), { recursive: true })
  97. const config = buildThreadConfig({
  98. access: ctx.access, permissionMode: ctx.permissionMode, settings: ctx.settings,
  99. codexBin: this.bin(ctx.settings), codexHome: codexHome(), allowPlugins: ctx.codexPlugins,
  100. })
  101. // 按 Profile 的技能选择禁用其余技能(只作用于本次对话)
  102. const skills = await this.listSkills(ctx.settings, ctx.workspaceDir).catch(() => [])
  103. const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings), ctx.codexPlugins, ctx.skillsExcludedFromAll)
  104. if (disabled.length) config.skills = { config: disabled }
  105. // 与 Claude 共用的 MCP Gateway;令牌每个 Run 不同,thread/start 与 thread/resume 都会重新传入
  106. config.mcp_servers = {
  107. [GATEWAY_SERVER]: {
  108. url: ctx.gateway.url,
  109. http_headers: { Authorization: `Bearer ${ctx.gateway.token}` },
  110. // 安全校验在网关中完成,不再逐次询问
  111. default_tools_approval_mode: 'approve',
  112. startup_timeout_sec: 20,
  113. // 第三方 MCP 工具可能先等待审批(交互超时)再执行(网关最多 10 分钟)
  114. tool_timeout_sec: gatewayToolTimeoutSec(ctx.settings),
  115. },
  116. }
  117. // 需审批:沙箱内的命令自动执行,越过沙箱边界时请求审批;自动/只读:从不请求,越界直接失败
  118. const approvalPolicy: AskForApproval = ctx.permissionMode === 'ask' ? 'on-request' : 'never'
  119. const threadParams = {
  120. cwd: ctx.workspaceDir, model: ctx.model, approvalPolicy, config,
  121. developerInstructions: ctx.systemPromptAppend,
  122. }
  123. // 模型每轮通过 turn/start 传入,不计入指纹
  124. const fingerprint = createHash('sha256').update(JSON.stringify({ ...threadParams, model: undefined })).digest('hex')
  125. let threadId = ctx.providerSessionId
  126. if (threadId && this.loaded.has(threadId) && this.loaded.get(threadId) !== fingerprint) {
  127. // 配置变了(Profile 的访问策略、权限模式、技能、全局禁止项、提示词等):分叉出新会话以采用新配置
  128. try {
  129. const from = threadId
  130. const r = await client.request<ThreadForkResponse>('thread/fork', { ...threadParams, threadId: from, excludeTurns: true })
  131. ctx.log.info({ from, to: r.thread.id }, 'codex 会话配置已变化,分叉为新会话')
  132. void client.request('thread/unsubscribe', { threadId: from }).catch(() => {})
  133. this.loaded.delete(from)
  134. threadId = r.thread.id
  135. ctx.setProviderSessionId(threadId)
  136. } catch (err) {
  137. // 分叉失败时不能继续使用旧配置,改为新会话(丢失上下文,但配置正确)
  138. ctx.log.warn({ err: (err as Error).message, threadId }, 'codex 会话分叉失败,以新会话继续')
  139. threadId = null
  140. }
  141. } else if (threadId && !this.loaded.has(threadId)) {
  142. try {
  143. await client.request('thread/resume', { ...threadParams, threadId, excludeTurns: true })
  144. } catch (err) {
  145. ctx.log.warn({ err: (err as Error).message, threadId }, 'codex 会话恢复失败,以新会话继续')
  146. threadId = null
  147. }
  148. }
  149. if (!threadId) {
  150. const r = await client.request<ThreadStartResponse>('thread/start', { ...threadParams, serviceName: 'agent-server' })
  151. threadId = r.thread.id
  152. ctx.setProviderSessionId(threadId)
  153. }
  154. this.loaded.set(threadId, fingerprint)
  155. const run = new ThreadRun(ctx, threadId)
  156. this.runs.set(threadId, run)
  157. const onAbort = () => {
  158. if (run.turnId) void client.request('turn/interrupt', { threadId, turnId: run.turnId }).catch(() => {})
  159. run.fail(new ProviderRunError('interrupted', '已中断'))
  160. }
  161. ctx.signal.addEventListener('abort', onAbort, { once: true })
  162. try {
  163. const r = await client.request<TurnStartResponse>('turn/start', { threadId, model: ctx.model, input: buildInput(ctx) })
  164. run.turnId = r.turn.id
  165. return await run.done
  166. } finally {
  167. ctx.signal.removeEventListener('abort', onAbort)
  168. this.runs.delete(threadId)
  169. }
  170. }
  171. private onNotification(method: string, params: unknown) {
  172. if (method === 'account/login/completed') {
  173. const p = params as { loginId: string | null; success: boolean; error: string | null }
  174. if (this.login && (!p.loginId || p.loginId === this.login.loginId)) {
  175. Object.assign(this.login, { status: p.success ? 'success' : 'failed', error: p.error })
  176. }
  177. return
  178. }
  179. const threadId = (params as { threadId?: string } | null)?.threadId
  180. if (threadId) this.runs.get(threadId)?.onNotification(method, params)
  181. }
  182. private async onServerRequest(method: string, params: unknown): Promise<unknown> {
  183. const threadId = (params as { threadId?: string } | null)?.threadId
  184. const run = threadId ? this.runs.get(threadId) : undefined
  185. if (!run) throw new Error(`没有对应的运行:${method}`)
  186. return run.onServerRequest(method, params)
  187. }
  188. /** Codex 在某个工作目录下可见的全部技能 */
  189. async listSkills(settings: AppSettings, cwd: string): Promise<SkillMetadata[]> {
  190. const client = await this.ensure(settings)
  191. const r = await client.request<SkillsListResponse>('skills/list', { cwds: [cwd], forceReload: true }, 30_000)
  192. return r.data.flatMap(e => e.skills)
  193. }
  194. /** 后台展示用:技能及其展示 ID(不含始终禁用的技能和远程插件的技能,后者不由 Profile 选择) */
  195. async skillInfos(settings: AppSettings): Promise<{ id: string; name: string; description: string; scope: string }[]> {
  196. const skills = await this.listSkills(settings, settings.paths.workspaceRoot)
  197. return skills.filter(s => !ALWAYS_DISABLED.has(s.name) && !s.pluginId)
  198. .map(s => ({ id: skillDisplayId(s, codexHome()), name: s.name, description: s.shortDescription ?? s.description, scope: s.scope }))
  199. }
  200. /** ChatGPT 订阅额度(account/rateLimits/read),缓存 1 分钟 */
  201. async quota(settings: AppSettings): Promise<ProviderQuotaDto> {
  202. if (this.quotaCache && Date.now() - this.quotaCache.at < 60_000) return this.quotaCache.value
  203. const client = await this.ensure(settings)
  204. const r = await client.request<GetAccountRateLimitsResponse>('account/rateLimits/read', {}, 30_000)
  205. const snapshots = Object.values(r.rateLimitsByLimitId ?? {}).filter((s): s is RateLimitSnapshot => !!s)
  206. const list = snapshots.length ? snapshots : [r.rateLimits]
  207. const windows = list.flatMap(s => quotaWindows(s, list.length > 1))
  208. const value: ProviderQuotaDto = {
  209. provider: 'codex', plan: list.find(s => s.planType)?.planType ?? null, windows, source: 'live', fetchedAt: new Date().toISOString(),
  210. message: r.ordinaryUsageAllowed === false ? '当前账户的额度已用完或不可用' : windows.length ? null : '账户没有返回额度信息',
  211. }
  212. this.quotaCache = { at: Date.now(), value }
  213. return value
  214. }
  215. /** 直接调用 app-server(插件目录等后台功能使用) */
  216. async request<T>(settings: AppSettings, method: string, params: unknown, timeoutMs = 60_000): Promise<T> {
  217. const client = await this.ensure(settings)
  218. return client.request<T>(method, params, timeoutMs)
  219. }
  220. // ---- 账户与登录(设备码方式:在 OpenAI 官方页面输入短码完成登录,AgentServer 不接触凭证) ----
  221. async startLogin(settings: AppSettings): Promise<CodexLoginState> {
  222. const client = await this.ensure(settings)
  223. if (this.login?.status === 'pending') await this.cancelLogin(settings).catch(() => {})
  224. const r = await client.request<LoginAccountResponse>('account/login/start', { type: 'chatgptDeviceCode' })
  225. if (r.type !== 'chatgptDeviceCode') throw new Error('codex 没有返回设备码')
  226. this.login = { loginId: r.loginId, verificationUrl: r.verificationUrl, userCode: r.userCode, status: 'pending', error: null }
  227. return this.login
  228. }
  229. loginState(): CodexLoginState | null {
  230. return this.login
  231. }
  232. async cancelLogin(settings: AppSettings) {
  233. if (!this.login || this.login.status !== 'pending') return
  234. const client = await this.ensure(settings)
  235. await client.request('account/login/cancel', { loginId: this.login.loginId }).catch(() => {})
  236. this.login.status = 'cancelled'
  237. }
  238. async logout(settings: AppSettings) {
  239. const client = await this.ensure(settings)
  240. await client.request('account/logout', {})
  241. }
  242. async status(settings: AppSettings): Promise<ProviderStatusDto> {
  243. const s = settings.providers.codex
  244. const base: ProviderStatusDto = {
  245. id: 'codex', enabled: s.enabled, available: false, loggedIn: null, account: null,
  246. cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null,
  247. }
  248. try {
  249. const client = await this.ensure(settings)
  250. const acc = await client.request<GetAccountResponse>('account/read', {}, 30_000)
  251. const loggedIn = !!acc.account
  252. let models = s.models
  253. const modelLabels: Record<string, string> = {}
  254. if (loggedIn) {
  255. const list = await client.request<ModelListResponse>('model/list', {}, 30_000).catch(() => null)
  256. if (list) {
  257. this.models = list.data.filter(m => !m.hidden).map(m => m.model)
  258. if (!models.length) models = this.models
  259. for (const m of list.data) if (m.displayName && m.displayName !== m.model) modelLabels[m.model] = m.displayName
  260. }
  261. }
  262. const account = acc.account?.type === 'chatgpt'
  263. ? [acc.account.email, `(${acc.account.planType})`].filter(Boolean).join(' ')
  264. : acc.account?.type ?? null
  265. return {
  266. ...base, version: this.version, loggedIn, account, models, modelLabels,
  267. defaultModel: s.defaultModel || models[0] || '',
  268. available: s.enabled && loggedIn,
  269. message: loggedIn ? (s.enabled ? null : '已登录,尚未在"设置 → 引擎"中启用') : '未登录:点击"登录"按钮,用 ChatGPT 账号完成设备码登录',
  270. }
  271. } catch (err) {
  272. return { ...base, message: `无法启动 codex app-server:${(err as Error).message}` }
  273. }
  274. }
  275. }
  276. export function defaultCodexPath() {
  277. return bundledCodexPath()
  278. }
  279. function buildInput(ctx: RunContext) {
  280. let text = ctx.input.text
  281. if (ctx.input.attachments.length) {
  282. const list = ctx.input.attachments.map(a => `- ${a.path}(${a.mime})`).join('\n')
  283. text += `\n\n<attachments>\n用户上传了以下文件,位于工作区中:\n${list}\n</attachments>`
  284. }
  285. const images = ctx.input.attachments
  286. .filter(a => ctx.input.images.some(i => i.name === a.name))
  287. .map(a => ({ type: 'localImage' as const, path: path.join(ctx.workspaceDir, a.path) }))
  288. return [{ type: 'text' as const, text, text_elements: [] }, ...images]
  289. }
  290. /** 一次 Run 对应的一个 turn:把通知映射为统一事件,处理审批与提问 */
  291. class ThreadRun {
  292. turnId: string | null = null
  293. readonly done: Promise<RunResult>
  294. private resolve!: (r: RunResult) => void
  295. private reject!: (e: Error) => void
  296. private settled = false
  297. private items = new Map<string, ThreadItem>()
  298. private denied = new Set<string>()
  299. private usageBase: UsagePayload | null = null
  300. private usageLatest: UsagePayload | null = null
  301. /** 最后一次模型调用的上下文大小 */
  302. private lastContext = 0
  303. constructor(private ctx: RunContext, private threadId: string) {
  304. this.done = new Promise((res, rej) => {
  305. this.resolve = res
  306. this.reject = rej
  307. })
  308. }
  309. fail(err: Error) {
  310. if (this.settled) return
  311. this.settled = true
  312. this.reject(err)
  313. }
  314. onNotification(method: string, params: unknown) {
  315. const emit = this.ctx.emit
  316. switch (method) {
  317. case 'item/started': {
  318. const { item } = params as ItemStartedNotification
  319. this.items.set(item.id, item)
  320. const tool = describeTool(item, this.ctx.workspaceDir)
  321. if (tool) emit('tool.started', { toolCallId: item.id, ...tool })
  322. if (item.type === 'commandExecution') this.ctx.audit('command', item.command.slice(0, 500), { itemId: item.id })
  323. break
  324. }
  325. case 'item/agentMessage/delta': {
  326. const p = params as { itemId: string; delta: string }
  327. emit('message.delta', { messageId: p.itemId, text: p.delta })
  328. break
  329. }
  330. case 'item/reasoning/summaryTextDelta': {
  331. const p = params as { itemId: string; delta: string }
  332. emit('reasoning.delta', { blockId: p.itemId, text: p.delta })
  333. break
  334. }
  335. case 'item/completed': {
  336. const { item } = params as ItemCompletedNotification
  337. this.items.set(item.id, item)
  338. this.onItemCompleted(item)
  339. break
  340. }
  341. case 'thread/tokenUsage/updated': {
  342. const { tokenUsage } = params as ThreadTokenUsageUpdatedNotification
  343. const toUsage = (b: typeof tokenUsage.total): UsagePayload => ({
  344. inputTokens: b.inputTokens - b.cachedInputTokens, outputTokens: b.outputTokens,
  345. cacheReadTokens: b.cachedInputTokens, cacheWriteTokens: b.cacheWriteInputTokens,
  346. })
  347. // 本次 Run 的用量 = 最新累计 −(第一次通知时的累计 − 那一次的增量)
  348. if (!this.usageBase) {
  349. const t = toUsage(tokenUsage.total), l = toUsage(tokenUsage.last)
  350. this.usageBase = {
  351. inputTokens: t.inputTokens - l.inputTokens, outputTokens: t.outputTokens - l.outputTokens,
  352. cacheReadTokens: t.cacheReadTokens - l.cacheReadTokens, cacheWriteTokens: t.cacheWriteTokens - l.cacheWriteTokens,
  353. }
  354. }
  355. this.usageLatest = toUsage(tokenUsage.total)
  356. this.lastContext = tokenUsage.last.inputTokens + tokenUsage.last.outputTokens
  357. break
  358. }
  359. case 'turn/completed': {
  360. const { turn } = params as TurnCompletedNotification
  361. if (this.settled) return
  362. if (turn.status === 'completed') {
  363. this.settled = true
  364. this.resolve({ usage: this.runUsage() })
  365. } else if (turn.status === 'interrupted') {
  366. this.fail(new ProviderRunError('interrupted', '已中断'))
  367. } else {
  368. this.fail(new ProviderRunError('codex_failed', turn.error?.message ?? '运行失败'))
  369. }
  370. break
  371. }
  372. case 'error': {
  373. const p = params as { error: { message: string }; willRetry: boolean }
  374. this.ctx.log.warn({ error: p.error.message, willRetry: p.willRetry }, 'codex error')
  375. break
  376. }
  377. }
  378. }
  379. private runUsage(): UsagePayload | null {
  380. if (!this.usageLatest || !this.usageBase) return null
  381. const b = this.usageBase, l = this.usageLatest
  382. return {
  383. inputTokens: l.inputTokens - b.inputTokens, outputTokens: l.outputTokens - b.outputTokens,
  384. cacheReadTokens: l.cacheReadTokens - b.cacheReadTokens, cacheWriteTokens: l.cacheWriteTokens - b.cacheWriteTokens,
  385. ...(this.lastContext ? { contextTokens: this.lastContext } : {}),
  386. }
  387. }
  388. private onItemCompleted(item: ThreadItem) {
  389. const emit = this.ctx.emit
  390. const done = (status: 'ok' | 'error' | 'denied', output: string) => emit('tool.completed', {
  391. toolCallId: item.id, status: this.denied.has(item.id) ? 'denied' : status,
  392. output: output.slice(0, MAX_TOOL_OUTPUT), truncated: output.length > MAX_TOOL_OUTPUT,
  393. })
  394. switch (item.type) {
  395. case 'agentMessage':
  396. if (item.text) emit('message.completed', { messageId: item.id, text: item.text })
  397. break
  398. case 'reasoning': {
  399. const text = item.summary.join('\n').trim()
  400. if (text) emit('reasoning.completed', { blockId: item.id, text })
  401. break
  402. }
  403. case 'commandExecution':
  404. done(item.status === 'declined' ? 'denied' : item.status === 'completed' && (item.exitCode ?? 0) === 0 ? 'ok' : 'error',
  405. `${item.aggregatedOutput ?? ''}${item.exitCode ? `\n(exit ${item.exitCode})` : ''}`.trim())
  406. break
  407. case 'fileChange':
  408. done(item.status === 'declined' ? 'denied' : item.status === 'completed' ? 'ok' : 'error',
  409. item.changes.map(c => `${c.kind} ${c.path}`).join('\n'))
  410. break
  411. case 'mcpToolCall':
  412. done(item.status === 'completed' ? 'ok' : 'error',
  413. item.error ? JSON.stringify(item.error) : (item.result?.content ?? []).map(c => (c as { text?: string }).text ?? '').join('\n'))
  414. break
  415. case 'webSearch':
  416. case 'imageView':
  417. done('ok', '')
  418. break
  419. }
  420. }
  421. async onServerRequest(method: string, params: unknown): Promise<unknown> {
  422. const ctx = this.ctx
  423. switch (method) {
  424. case 'item/commandExecution/requestApproval': {
  425. const p = params as CommandExecutionRequestApprovalParams
  426. // 网络访问不放开:Codex 的命令一旦联网就是无限制的(可访问内网)
  427. if (p.networkApprovalContext) {
  428. this.denied.add(p.itemId)
  429. ctx.audit('network_denied', `拒绝命令联网:${p.command ?? ''}`.slice(0, 500), { networkApprovalContext: p.networkApprovalContext })
  430. return { decision: 'decline' }
  431. }
  432. if (ctx.permissionMode === 'readonly') return { decision: 'decline' }
  433. const command = p.command ?? (this.items.get(p.itemId) as { command?: string } | undefined)?.command ?? ''
  434. const outcome = await ctx.interact({
  435. kind: 'approval', tool: 'Bash', title: commandTitle(command) || '执行命令',
  436. input: { command, cwd: p.cwd }, detail: { command, reason: p.reason ?? undefined },
  437. options: ['allow_once', 'allow_session', 'deny'],
  438. })
  439. ctx.audit('approval', `codex 命令:${outcome.resolution}`, { command })
  440. if (outcome.resolution === 'allowed') return { decision: outcome.scope === 'session' ? 'acceptForSession' : 'accept' }
  441. this.denied.add(p.itemId)
  442. return { decision: outcome.resolution === 'denied' ? 'decline' : 'cancel' }
  443. }
  444. case 'item/fileChange/requestApproval': {
  445. const p = params as FileChangeRequestApprovalParams
  446. const item = this.items.get(p.itemId)
  447. const paths = item?.type === 'fileChange' ? item.changes.map(c => path.resolve(ctx.workspaceDir, c.path)) : []
  448. if (p.grantRoot) paths.push(path.resolve(ctx.workspaceDir, p.grantRoot))
  449. const blocked = paths.map(f => ({ f, d: checkAccess(ctx.access, f, 'write') })).find(x => !x.d.allowed)
  450. if (ctx.permissionMode === 'readonly' || blocked || !paths.length) {
  451. this.denied.add(p.itemId)
  452. if (blocked && !blocked.d.allowed) ctx.audit('file_access_denied', blocked.d.reason, { tool: 'codex fileChange', paths })
  453. return { decision: 'decline' }
  454. }
  455. const outcome = await ctx.interact({
  456. kind: 'approval', tool: 'Edit', title: paths.map(f => path.relative(ctx.workspaceDir, f)).join('、'),
  457. input: { paths }, detail: { path: paths[0], reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'],
  458. })
  459. if (outcome.resolution === 'allowed') return { decision: outcome.scope === 'session' ? 'acceptForSession' : 'accept' }
  460. this.denied.add(p.itemId)
  461. return { decision: outcome.resolution === 'denied' ? 'decline' : 'cancel' }
  462. }
  463. case 'item/permissions/requestApproval': {
  464. // 申请额外权限:联网一律拒绝;文件路径必须在 Profile 的访问策略之内,然后由人审批
  465. const p = params as PermissionsRequestApprovalParams
  466. const reqFs = p.permissions.fileSystem
  467. const reads = (reqFs?.read ?? []).map(f => path.resolve(p.cwd, f))
  468. const writes = (reqFs?.write ?? []).map(f => path.resolve(p.cwd, f))
  469. const outside = [...reads.map(f => checkAccess(ctx.access, f, 'read')), ...writes.map(f => checkAccess(ctx.access, f, 'write'))].find(d => !d.allowed)
  470. const wantsNetwork = !!p.permissions.network?.enabled
  471. if (wantsNetwork) ctx.audit('network_denied', '拒绝 codex 申请联网权限', { reason: p.reason })
  472. if (outside && !outside.allowed) ctx.audit('file_access_denied', outside.reason, { reads, writes })
  473. if (wantsNetwork || outside || (!reads.length && !writes.length) || ctx.permissionMode !== 'ask') {
  474. return { permissions: {}, scope: 'turn' }
  475. }
  476. const outcome = await ctx.interact({
  477. kind: 'approval', tool: 'Permissions', title: `申请访问:${[...reads, ...writes].join('、')}`.slice(0, 300),
  478. input: p.permissions, detail: { reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'],
  479. })
  480. if (outcome.resolution !== 'allowed') return { permissions: {}, scope: 'turn' }
  481. return { permissions: { fileSystem: { read: reads, write: writes } }, scope: outcome.scope === 'session' ? 'session' : 'turn' }
  482. }
  483. case 'item/tool/requestUserInput': {
  484. const p = params as ToolRequestUserInputParams
  485. const outcome = await ctx.interact({
  486. kind: 'question',
  487. questions: p.questions.map(q => ({
  488. id: q.id, question: q.question, header: q.header, multiSelect: false,
  489. options: (q.options ?? []).map(o => ({ label: o.label, description: o.description })),
  490. })),
  491. })
  492. const answers: Record<string, { answers: string[] }> = {}
  493. if (outcome.resolution === 'answered') {
  494. for (const q of p.questions) {
  495. const a = outcome.answers[q.id]
  496. if (a !== undefined) answers[q.id] = { answers: Array.isArray(a) ? a : [a] }
  497. }
  498. }
  499. return { answers }
  500. }
  501. case 'mcpServer/elicitation/request': {
  502. // 应用连接器的调用确认以 elicitation 形式到达:只在开放插件的会话(Playground)中,
  503. // 且不需要填写内容时转成审批卡片;需要填表或跳转网址的请求一律拒绝
  504. const p = params as McpServerElicitationRequestParams
  505. const schema = 'requestedSchema' in p ? p.requestedSchema as { properties?: Record<string, unknown> } | null : null
  506. const needsInput = p.mode === 'url' || Object.keys(schema?.properties ?? {}).length > 0
  507. if (!ctx.codexPlugins || p.serverName === GATEWAY_SERVER || needsInput) {
  508. ctx.audit('approval', `拒绝 MCP 请求(${p.serverName},${p.mode}):${p.message}`.slice(0, 500))
  509. return { action: 'decline', content: null, _meta: null }
  510. }
  511. const outcome = await ctx.interact({
  512. kind: 'approval', tool: `mcp__${p.serverName}`, title: p.message.split('\n')[0]!.slice(0, 200),
  513. input: { server: p.serverName }, detail: { reason: p.message }, options: ['allow_once', 'deny'],
  514. })
  515. ctx.audit('approval', `codex 应用调用(${p.serverName}):${outcome.resolution}`, { message: p.message })
  516. return { action: outcome.resolution === 'allowed' ? 'accept' : 'decline', content: outcome.resolution === 'allowed' ? {} : null, _meta: null }
  517. }
  518. default:
  519. throw new RpcRequestError(method, { code: -32601, message: `AgentServer 不支持:${method}` })
  520. }
  521. }
  522. }
  523. function quotaWindows(s: RateLimitSnapshot, named: boolean): QuotaWindowDto[] {
  524. const label = (mins: number | null) => (mins === null ? '窗口' : mins % 1440 === 0 ? `${mins / 1440} 天` : mins % 60 === 0 ? `${mins / 60} 小时` : `${mins} 分钟`)
  525. const prefix = named && (s.limitName ?? s.limitId) ? `${s.limitName ?? s.limitId} · ` : ''
  526. return ([['primary', s.primary], ['secondary', s.secondary]] as const).flatMap(([k, w]) => (w ? [{
  527. key: `${s.limitId ?? 'default'}:${k}`, label: prefix + label(w.windowDurationMins), usedPercent: w.usedPercent,
  528. resetsAt: w.resetsAt ? new Date(w.resetsAt * 1000).toISOString() : null,
  529. }] : []))
  530. }
  531. /** Codex 把命令包成 `/usr/bin/bash -lc "..."`,显示时去掉外层,只保留第一行 */
  532. export function commandTitle(command: string): string {
  533. let c = command.trim()
  534. const m = /^(?:\/usr)?(?:\/bin\/)?(?:ba|z)?sh\s+-l?c\s+([\s\S]*)$/.exec(c)
  535. if (m) {
  536. c = m[1]!.trim()
  537. if ((c.startsWith('"') && c.endsWith('"')) || (c.startsWith("'") && c.endsWith("'"))) c = c.slice(1, -1)
  538. }
  539. return c.split('\n')[0]!.slice(0, 200)
  540. }
  541. function describeTool(item: ThreadItem, cwd: string): { name: string; title: string; input: unknown } | null {
  542. const rel = (p: string) => {
  543. const r = path.relative(cwd, path.resolve(cwd, p))
  544. return r.startsWith('..') ? p : r
  545. }
  546. switch (item.type) {
  547. case 'commandExecution':
  548. return { name: 'Bash', title: commandTitle(item.command), input: { command: item.command, cwd: item.cwd } }
  549. case 'fileChange':
  550. 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 })) } }
  551. case 'mcpToolCall': {
  552. // 标题取最有代表性的参数:网址 / 输入文件(及目标格式)
  553. const a = (item.arguments ?? {}) as Record<string, unknown>
  554. const title = typeof a.url === 'string' ? a.url
  555. : typeof a.input === 'string' ? `${rel(a.input)}${typeof a.format === 'string' ? ` → ${a.format}` : ''}` : ''
  556. return { name: `mcp__${item.server}__${item.tool}`, title, input: item.arguments }
  557. }
  558. case 'webSearch':
  559. return { name: 'WebSearch', title: (item as { query?: string }).query ?? '', input: { query: (item as { query?: string }).query } }
  560. case 'imageView':
  561. return { name: 'Read', title: rel(item.path), input: { path: item.path } }
  562. default:
  563. return null
  564. }
  565. }
  566. function localPluginName(settings: AppSettings): string {
  567. try {
  568. return (JSON.parse(fs.readFileSync(path.join(settings.paths.skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
  569. } catch {
  570. return 'agent-skills'
  571. }
  572. }