| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- // Codex 适配器:codex app-server(JSON-RPC)→ 统一事件(docs/design/03-providers.md §3,ADR-0003)
- import { createHash } from 'node:crypto'
- import fs from 'node:fs'
- import path from 'node:path'
- import type { AppSettings, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
- import { communitySkillsDir } from '../../config'
- import { checkAccess } from '../../services/access-policy'
- import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
- import { bundledCodexPath, codexHome } from './cli'
- import { GATEWAY_SERVER } from '../../mcp-gateway/server'
- import { buildThreadConfig } from './permissions'
- import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills'
- import type {
- AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountResponse,
- ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse, SkillMetadata, SkillsListResponse,
- McpServerElicitationRequestParams, PermissionsRequestApprovalParams, ThreadForkResponse, 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<AppServerClient> | null = null
- private clientKey = ''
- private runs = new Map<string, ThreadRun>()
- /**
- * 当前 app-server 进程中已加载的会话及其加载时的配置指纹。已加载会话的配置不会被 thread/resume 更新
- * (已验证,codex 0.164),配置变化时改为 thread/fork:保留历史、采用新配置(ADR-0020)
- */
- private loaded = new Map<string, string>()
- 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<AppServerClient> {
- 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 已退出')
- this.loaded.clear()
- 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 }
- // 本地技能目录(自写技能)与第三方技能目录与 Claude 共用,作为额外技能根目录
- const extraRoots = [path.join(settings.paths.skillsDir, 'skills'), path.join(communitySkillsDir(), 'skills')].filter(d => fs.existsSync(d))
- if (extraRoots.length) await client.request('skills/extraRoots/set', { extraRoots }).catch(err => this.log.warn({ err: (err as Error).message }, 'codex extraRoots 设置失败'))
- // 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<RunResult> {
- 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(), allowPlugins: ctx.codexPlugins,
- })
- // 按 Profile 的技能选择禁用其余技能(只作用于本次对话)
- const skills = await this.listSkills(ctx.settings, ctx.workspaceDir).catch(() => [])
- const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings), ctx.codexPlugins)
- if (disabled.length) config.skills = { config: disabled }
- // 与 Claude 共用的 MCP Gateway;令牌每个 Run 不同,thread/start 与 thread/resume 都会重新传入
- config.mcp_servers = {
- [GATEWAY_SERVER]: {
- url: ctx.gateway.url,
- http_headers: { Authorization: `Bearer ${ctx.gateway.token}` },
- // 安全校验在网关中完成,不再逐次询问
- default_tools_approval_mode: 'approve',
- startup_timeout_sec: 20,
- tool_timeout_sec: 300,
- },
- }
- // 需审批:沙箱内的命令自动执行,越过沙箱边界时请求审批;自动/只读:从不请求,越界直接失败
- const approvalPolicy: AskForApproval = ctx.permissionMode === 'ask' ? 'on-request' : 'never'
- const threadParams = {
- cwd: ctx.workspaceDir, model: ctx.model, approvalPolicy, config,
- developerInstructions: ctx.systemPromptAppend,
- }
- // 模型每轮通过 turn/start 传入,不计入指纹
- const fingerprint = createHash('sha256').update(JSON.stringify({ ...threadParams, model: undefined })).digest('hex')
- let threadId = ctx.providerSessionId
- if (threadId && this.loaded.has(threadId) && this.loaded.get(threadId) !== fingerprint) {
- // 配置变了(Profile 的访问策略、权限模式、技能、全局禁止项、提示词等):分叉出新会话以采用新配置
- try {
- const from = threadId
- const r = await client.request<ThreadForkResponse>('thread/fork', { ...threadParams, threadId: from, excludeTurns: true })
- ctx.log.info({ from, to: r.thread.id }, 'codex 会话配置已变化,分叉为新会话')
- void client.request('thread/unsubscribe', { threadId: from }).catch(() => {})
- this.loaded.delete(from)
- threadId = r.thread.id
- ctx.setProviderSessionId(threadId)
- } catch (err) {
- // 分叉失败时不能继续使用旧配置,改为新会话(丢失上下文,但配置正确)
- ctx.log.warn({ err: (err as Error).message, threadId }, 'codex 会话分叉失败,以新会话继续')
- threadId = null
- }
- } else if (threadId && !this.loaded.has(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<ThreadStartResponse>('thread/start', { ...threadParams, serviceName: 'agent-server' })
- threadId = r.thread.id
- ctx.setProviderSessionId(threadId)
- }
- this.loaded.set(threadId, fingerprint)
- 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<TurnStartResponse>('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<unknown> {
- 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)
- }
- /** Codex 在某个工作目录下可见的全部技能 */
- async listSkills(settings: AppSettings, cwd: string): Promise<SkillMetadata[]> {
- const client = await this.ensure(settings)
- const r = await client.request<SkillsListResponse>('skills/list', { cwds: [cwd], forceReload: true }, 30_000)
- return r.data.flatMap(e => e.skills)
- }
- /** 后台展示用:技能及其展示 ID(不含始终禁用的技能和远程插件的技能,后者不由 Profile 选择) */
- async skillInfos(settings: AppSettings): Promise<{ id: string; name: string; description: string; scope: string }[]> {
- const skills = await this.listSkills(settings, settings.paths.workspaceRoot)
- return skills.filter(s => !ALWAYS_DISABLED.has(s.name) && !s.pluginId)
- .map(s => ({ id: skillDisplayId(s, codexHome()), name: s.name, description: s.shortDescription ?? s.description, scope: s.scope }))
- }
- /** 直接调用 app-server(插件目录等后台功能使用) */
- async request<T>(settings: AppSettings, method: string, params: unknown, timeoutMs = 60_000): Promise<T> {
- const client = await this.ensure(settings)
- return client.request<T>(method, params, timeoutMs)
- }
- // ---- 账户与登录(设备码方式:在 OpenAI 官方页面输入短码完成登录,AgentServer 不接触凭证) ----
- async startLogin(settings: AppSettings): Promise<CodexLoginState> {
- const client = await this.ensure(settings)
- if (this.login?.status === 'pending') await this.cancelLogin(settings).catch(() => {})
- const r = await client.request<LoginAccountResponse>('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<ProviderStatusDto> {
- 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<GetAccountResponse>('account/read', {}, 30_000)
- const loggedIn = !!acc.account
- let models = s.models
- if (loggedIn) {
- const list = await client.request<ModelListResponse>('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<attachments>\n用户上传了以下文件,位于工作区中:\n${list}\n</attachments>`
- }
- 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<RunResult>
- private resolve!: (r: RunResult) => void
- private reject!: (e: Error) => void
- private settled = false
- private items = new Map<string, ThreadItem>()
- private denied = new Set<string>()
- 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<unknown> {
- 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<string, { answers: string[] }> = {}
- 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': {
- // 应用连接器的调用确认以 elicitation 形式到达:只在开放插件的会话(Playground)中,
- // 且不需要填写内容时转成审批卡片;需要填表或跳转网址的请求一律拒绝
- const p = params as McpServerElicitationRequestParams
- const schema = 'requestedSchema' in p ? p.requestedSchema as { properties?: Record<string, unknown> } | null : null
- const needsInput = p.mode === 'url' || Object.keys(schema?.properties ?? {}).length > 0
- if (!ctx.codexPlugins || p.serverName === GATEWAY_SERVER || needsInput) {
- ctx.audit('approval', `拒绝 MCP 请求(${p.serverName},${p.mode}):${p.message}`.slice(0, 500))
- return { action: 'decline', content: null, _meta: null }
- }
- const outcome = await ctx.interact({
- kind: 'approval', tool: `mcp__${p.serverName}`, title: p.message.split('\n')[0]!.slice(0, 200),
- input: { server: p.serverName }, detail: { reason: p.message }, options: ['allow_once', 'deny'],
- })
- ctx.audit('approval', `codex 应用调用(${p.serverName}):${outcome.resolution}`, { message: p.message })
- return { action: outcome.resolution === 'allowed' ? 'accept' : 'decline', content: outcome.resolution === 'allowed' ? {} : 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': {
- // 标题取最有代表性的参数:网址 / 输入文件(及目标格式)
- const a = (item.arguments ?? {}) as Record<string, unknown>
- const title = typeof a.url === 'string' ? a.url
- : typeof a.input === 'string' ? `${rel(a.input)}${typeof a.format === 'string' ? ` → ${a.format}` : ''}` : ''
- 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
- }
- }
- function localPluginName(settings: AppSettings): string {
- try {
- return (JSON.parse(fs.readFileSync(path.join(settings.paths.skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
- } catch {
- return 'agent-skills'
- }
- }
|