adapter.ts 30 KB

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