Ver Fonte

fix: Codex 会话配置变化时分叉,使新配置立即生效

- 已加载的 Codex 会话不会因 thread/resume 更新配置(已实测:resume 时放开目录,命令仍读不到)
- 适配器记录每个已加载会话的配置指纹(访问策略、权限模式、技能、全局禁止项、功能开关、系统提示词);
  指纹变化时用 thread/fork 分叉:保留全部历史、采用新配置,并更新会话映射;分叉失败时新建会话
- 实测:fork 后放宽可读、再收紧又被拒绝,历史轮数不变;配置不变的多轮对话照常续用
- ADR-0020;03 文档中的已知限制改为机制说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang há 1 semana atrás
pai
commit
687100ba4a

+ 27 - 2
apps/server/src/providers/codex/adapter.ts

@@ -1,4 +1,5 @@
 // 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'
@@ -12,7 +13,7 @@ import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills'
 import type {
   AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountResponse,
   ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse, SkillMetadata, SkillsListResponse,
-  McpServerElicitationRequestParams, PermissionsRequestApprovalParams, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification,
+  McpServerElicitationRequestParams, PermissionsRequestApprovalParams, ThreadForkResponse, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification,
   ToolRequestUserInputParams, TurnCompletedNotification, TurnStartResponse,
 } from './protocol/v2'
 import { AppServerClient, RpcRequestError } from './rpc'
@@ -36,6 +37,11 @@ export class CodexProvider implements AgentProvider {
   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
@@ -72,6 +78,7 @@ export class CodexProvider implements AgentProvider {
       })
       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})`))
       })
@@ -120,8 +127,25 @@ export class CodexProvider implements AgentProvider {
       developerInstructions: ctx.systemPromptAppend,
     }
 
+    // 模型每轮通过 turn/start 传入,不计入指纹
+    const fingerprint = createHash('sha256').update(JSON.stringify({ ...threadParams, model: undefined })).digest('hex')
     let threadId = ctx.providerSessionId
-    if (threadId) {
+    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) {
@@ -134,6 +158,7 @@ export class CodexProvider implements AgentProvider {
       threadId = r.thread.id
       ctx.setProviderSessionId(threadId)
     }
+    this.loaded.set(threadId, fingerprint)
 
     const run = new ThreadRun(ctx, threadId)
     this.runs.set(threadId, run)

+ 20 - 0
apps/server/test/codex.test.ts

@@ -127,6 +127,26 @@ describe('CodexProvider(模拟 app-server)', () => {
     expect(t.events.some(e => (e.payload as { text?: string }).text === 'answer:{"chart":{"answers":["折线图"]}}')).toBe(true)
   })
 
+  it('同一会话配置不变时沿用;配置变化时分叉出新会话并采用新配置', async () => {
+    const t1 = makeCtx('你好')
+    await provider.run(t1.ctx)
+    const first = t1.sessionId()!
+    const t2 = makeCtx('你好', { providerSessionId: first })
+    t2.ctx.workspaceDir = t1.ctx.workspaceDir
+    t2.ctx.access = t1.ctx.access
+    await provider.run(t2.ctx)
+    expect(t2.sessionId()).toBeNull() // 未改变会话 ID
+
+    // 改为只读:工作区权限从 write 变为 read
+    const t3 = makeCtx('你好', { providerSessionId: first, mode: 'readonly' })
+    t3.ctx.workspaceDir = t1.ctx.workspaceDir
+    t3.ctx.access = t1.ctx.access
+    await provider.run(t3.ctx)
+    expect(t3.sessionId()).toMatch(/^f-/)
+    const cfg = JSON.parse((t3.events.find(e => (e.payload as { messageId?: string }).messageId === 'cfg')!.payload as { text: string }).text)
+    expect(cfg.permissions.agent_server.filesystem[':workspace_roots']['.']).toBe('read')
+  })
+
   it('恢复不存在的会话时新建;中断正在运行的 turn', async () => {
     const t = makeCtx('你好', { providerSessionId: 't-999' })
     await provider.run(t.ctx)

+ 7 - 0
apps/server/test/fixtures-fake-codex.mjs

@@ -29,6 +29,13 @@ readline.createInterface({ input: process.stdin }).on('line', async line => {
     case 'thread/resume':
       if (!threads.has(params.threadId)) return send({ id, error: { code: -32000, message: 'thread not found' } })
       return reply({ thread: { id: params.threadId } })
+    case 'thread/fork': {
+      if (!threads.has(params.threadId)) return send({ id, error: { code: -32000, message: 'thread not found' } })
+      const tid = 'f-' + (threads.size + 1); threads.add(tid)
+      globalThis.lastConfig = params.config
+      globalThis.forks = [...(globalThis.forks ?? []), params.threadId]
+      return reply({ thread: { id: tid }, model: params.model })
+    }
     case 'turn/interrupt': return reply({})
     case 'skills/extraRoots/set': globalThis.extraRoots = params.extraRoots; return reply({})
     case 'skills/list': return reply({ data: [{ cwd: params.cwds[0], errors: [], skills: [

+ 1 - 0
docs/README.md

@@ -49,6 +49,7 @@ docs/
 | [0017](adr/0017-community-skills.md) | 第三方技能:skills.sh 目录 + GitHub 固定提交,两个引擎共用 | Accepted |
 | [0018](adr/0018-codex-curated-plugins-playground-only.md) | Codex 插件(openai-curated)只在 Playground 中使用 | Accepted |
 | [0019](adr/0019-browser-tokens-and-sdk-distribution.md) | 浏览器令牌、CORS 与 SDK 包分发 | Accepted |
+| [0020](adr/0020-codex-fork-on-config-change.md) | Codex 会话配置变化时分叉 | Accepted |
 
 ## 调研
 

+ 26 - 0
docs/adr/0020-codex-fork-on-config-change.md

@@ -0,0 +1,26 @@
+# ADR-0020 Codex 会话配置变化时分叉
+
+- 状态:Accepted · 日期:2026-10-23 · 补充 ADR-0003
+
+## 背景
+AgentServer 每轮对话都会把最新配置(Profile 的文件访问策略、权限模式、技能、全局禁止项、功能开关、系统提示词)通过 `thread/resume` 传给 Codex。但会话一旦加载进 app-server 进程,Codex 就沿用首次加载时的配置。
+
+- 验证结果(codex 0.164):resume 时放开某个目录,命令仍然读不到
+- `thread/unsubscribe` 不会卸载会话
+- `turn/start` 没有权限覆盖项
+
+这样后台收紧安全设置(例如新增全局禁止项)后,进行中的 Codex 会话要到服务重启才会受约束。
+
+## 决策
+- 适配器为当前 app-server 进程中每个已加载的会话记录配置指纹:线程参数的 SHA-256,模型除外,模型每轮单独传入。
+- 下一轮的配置与指纹相同时直接继续,指纹不同时调用 `thread/fork`,传入新配置:
+  - 新会话保留全部历史,采用新配置
+  - 会话映射更新为新的 thread id,旧会话取消订阅
+  - 已验证:放宽后可以读取,再收紧后又被拒绝,历史轮数不变
+- 分叉失败时新建会话,宁可丢失上下文,也不在旧配置下继续。
+- app-server 重启后,已加载记录清空,下一轮按正常流程 resume。
+
+## 后果
+- 配置修改在下一轮立即生效,不需要重启服务
+- 分叉后,Codex 在本会话中"本会话内允许"的审批记录不再保留,需要重新审批
+- 旧会话的记录留在 Codex 自己的历史中,不影响 AgentServer

+ 5 - 1
docs/design/03-providers.md

@@ -140,7 +140,11 @@ Thread 参数:`approvalPolicy = on-request`、`cwd = workspaceDir`,沙箱使
   - 每次 Run 开始时绑定到当前 Run,结束后失效
   - 会话删除时清除
   - 以前每个 Run 换新令牌,导致同一会话第二轮起网关调用返回 401
-- **已知限制**:同理,已加载会话的沙箱权限配置也不会因 resume 更新(`thread/unsubscribe` 不会卸载会话,`turn/start` 没有权限覆盖项)。会话进行中修改 Profile 的文件访问策略,要等新会话或服务重启后才对 Codex 命令生效;网关工具每次 Run 都使用最新策略
+- **配置变化时分叉会话**(ADR-0020):已加载会话的配置不会因 `thread/resume` 更新(已验证:同一会话 resume 时传入新的访问策略,命令仍按旧策略执行)。
+  - 适配器为每个已加载会话记录配置指纹,覆盖每轮传入的全部配置,包括访问策略、权限模式、技能、全局禁止项、功能开关和系统提示词
+  - 指纹变化时改用 `thread/fork`:保留全部历史,采用新配置,并把会话映射更新为新的 thread id;放宽和收紧都已验证
+  - 分叉失败时新建会话(丢失上下文,但配置正确)
+  - app-server 重启后,已加载记录清空,下一轮正常 resume
 
 ## 4. 能力差异与对齐