فهرست منبع

fix: Codex 对话关闭远程插件与应用连接器;网关令牌按会话固定

- 服务账号登录 ChatGPT 后,Codex 自动安装 openai-curated-remote 插件(Google Drive、Gmail、Pages、Sites 等),
  其技能、应用连接器和插件安装建议会进入对话。每次对话的 config 关闭 plugins / remote_plugin / apps /
  tool_suggest / skill_mcp_dependency_install / multi_agent / browser_use / computer_use,apps 默认禁用
- Codex 会话保持加载时 MCP 连接沿用首轮请求头,导致第二轮起网关调用 401。Codex 改为每个会话一个固定令牌,
  每次 Run 重新绑定、结束后失效,删除会话时清除
- 文档记录功能基线、令牌机制与"已加载会话的权限配置不随 resume 更新"的已知限制

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 هفته پیش
والد
کامیت
27ebc9ecce

+ 1 - 0
apps/server/src/http/routes/v1.ts

@@ -94,6 +94,7 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
 
   f.delete<IdParams>('/sessions/:id', async (req, reply) => {
     await app.sessions.delete(req.params.id, principal(req).source)
+    app.gatewayTokens.forgetSession(req.params.id)
     reply.code(204)
   })
 

+ 19 - 3
apps/server/src/mcp-gateway/tokens.ts

@@ -1,4 +1,6 @@
-// 每个 Run 一个短期令牌:引擎调用网关时携带,网关据此确定会话、工作区与访问策略;Run 结束即作废
+// 每个 Run 一个短期令牌:引擎调用网关时携带,网关据此确定会话、工作区与访问策略;Run 结束即作废。
+// Codex 的会话在 app-server 中保持加载,MCP 连接沿用第一次对话时的请求头(thread/resume 不会更新),
+// 所以 Codex 每个会话使用固定的令牌字符串:每次 Run 开始时重新绑定到当前 Run,结束后失效,下次 Run 再绑定
 import crypto from 'node:crypto'
 import type { AppSettings, KnowledgeRootDto, PermissionMode } from '@agent-server/shared'
 import type { ResolvedAccess } from '../services/access-policy'
@@ -19,9 +21,15 @@ const MAX_AGE_MS = 24 * 3600 * 1000
 
 export class RunTokenRegistry {
   private tokens = new Map<string, { ctx: GatewayRunContext; issuedAt: number }>()
+  /** 会话 → 固定令牌(只用于 Codex) */
+  private sessionTokens = new Map<string, string>()
 
-  issue(ctx: GatewayRunContext): string {
-    const token = `rt_${crypto.randomBytes(32).toString('base64url')}`
+  issue(ctx: GatewayRunContext, opts: { stablePerSession?: boolean } = {}): string {
+    let token = opts.stablePerSession ? this.sessionTokens.get(ctx.sessionId) : undefined
+    if (!token) {
+      token = `rt_${crypto.randomBytes(32).toString('base64url')}`
+      if (opts.stablePerSession) this.sessionTokens.set(ctx.sessionId, token)
+    }
     this.tokens.set(token, { ctx, issuedAt: Date.now() })
     return token
   }
@@ -37,7 +45,15 @@ export class RunTokenRegistry {
     return e.ctx
   }
 
+  /** Run 结束:令牌失效(固定令牌的字符串保留,下次 Run 重新绑定) */
   revoke(token: string) {
     this.tokens.delete(token)
   }
+
+  /** 会话删除时调用 */
+  forgetSession(sessionId: string) {
+    const token = this.sessionTokens.get(sessionId)
+    if (token) this.tokens.delete(token)
+    this.sessionTokens.delete(sessionId)
+  }
 }

+ 21 - 0
apps/server/src/providers/codex/permissions.ts

@@ -8,6 +8,23 @@ import { hasGlob, realpathLoose } from '../../lib/paths'
 
 export const PROFILE_NAME = 'agent_server'
 
+/**
+ * 服务场景下关闭的 Codex 功能(每次对话的 config 覆盖)。已验证(2026-10-23,codex 0.164):服务账号登录 ChatGPT 后,
+ * Codex 会自动安装 openai-curated-remote 中的插件(Google Drive、Gmail、Pages、Sites 等),把插件技能、应用连接器
+ * 和插件安装建议带进对话,调用方可能借此访问账号绑定的个人数据。对话只使用 AgentServer 提供的能力(技能、MCP Gateway)
+ */
+export const DISABLED_FEATURES = {
+  plugins: false,
+  remote_plugin: false,
+  apps: false,
+  tool_suggest: false,
+  skill_mcp_dependency_install: false,
+  // 子代理的事件与审批不经过 AgentServer
+  multi_agent: false,
+  browser_use: false,
+  computer_use: false,
+} as const
+
 type Access = 'read' | 'write' | 'deny'
 type FsTable = Record<string, Access | Record<string, Access> | number>
 
@@ -15,6 +32,7 @@ export interface CodexThreadConfig {
   default_permissions: string
   permissions: Record<string, { filesystem: FsTable; network: { enabled: boolean } }>
   shell_environment_policy: { inherit: 'core'; set: Record<string, string> }
+  features: Record<string, boolean>
   [key: string]: unknown
 }
 
@@ -61,6 +79,9 @@ export function buildThreadConfig(opts: {
   const py = opts.settings.paths.pythonRuntime
   const agentDir = path.join(access.workspaceDir, '.agent')
   return {
+    features: { ...DISABLED_FEATURES },
+    // 即使有应用被启用,也不暴露连接器工具
+    apps: { _default: { enabled: false, destructive_enabled: false, open_world_enabled: false } },
     default_permissions: PROFILE_NAME,
     permissions: { [PROFILE_NAME]: { filesystem: fs, network: { enabled: false } } },
     // 只继承基础环境变量(PATH、HOME 等),不把服务进程中的密钥带进命令

+ 1 - 1
apps/server/src/services/runs.ts

@@ -177,7 +177,7 @@ export class RunManager {
         sessionId, runId, provider: session.provider, workspaceDir: session.workspaceDir, access, permissionMode, settings,
         knowledgeRoots: knowledgeRoots.filter(k => k.enabled && profile.knowledgeRootIds.includes(k.id)),
         audit: (kind, summary, detail) => deps.audit.log({ actor: `agent:${sessionRow.provider}`, sessionId, kind, summary, detail }),
-      })
+      }, { stablePerSession: session.provider === 'codex' })
 
       const result = await provider.run({
         sessionId,

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

@@ -61,6 +61,9 @@ describe('CodexProvider(模拟 app-server)', () => {
     expect(cfg.shell_environment_policy.inherit).toBe('core')
     // Profile 选择 all:只禁用始终禁用的系统技能
     expect(cfg.skills).toEqual({ config: [{ path: '/sys/skill-installer/SKILL.md', enabled: false }] })
+    // 插件、应用连接器、插件安装建议与子代理在服务中关闭
+    expect(cfg.features).toMatchObject({ plugins: false, remote_plugin: false, apps: false, tool_suggest: false, multi_agent: false })
+    expect(cfg.apps._default.enabled).toBe(false)
     // 网关以 HTTP MCP 方式配置,携带本次 Run 的令牌
     expect(cfg.mcp_servers.gateway).toMatchObject({ url: 'http://127.0.0.1:9/mcp', http_headers: { Authorization: 'Bearer rt_test' }, default_tools_approval_mode: 'approve' })
   })

+ 16 - 0
apps/server/test/gateway.test.ts

@@ -116,4 +116,20 @@ describe('MCP Gateway', () => {
     app.gatewayTokens.revoke(token)
     await expect(connect(token)).rejects.toThrow()
   })
+
+  it('Codex 会话使用固定令牌:每次 Run 重新绑定,Run 之间失效', () => {
+    const reg = app.gatewayTokens
+    const t1 = reg.issue(runCtx({ sessionId: 'ses_fixed', runId: 'r1' }), { stablePerSession: true })
+    reg.revoke(t1)
+    expect(reg.get(t1)).toBeNull()
+    const t2 = reg.issue(runCtx({ sessionId: 'ses_fixed', runId: 'r2' }), { stablePerSession: true })
+    expect(t2).toBe(t1)
+    expect(reg.get(t2)?.runId).toBe('r2')
+    // 其他会话、非固定令牌各不相同
+    expect(reg.issue(runCtx({ sessionId: 'ses_other' }), { stablePerSession: true })).not.toBe(t1)
+    expect(reg.issue(runCtx({ sessionId: 'ses_fixed' }))).not.toBe(t1)
+    reg.forgetSession('ses_fixed')
+    expect(reg.get(t1)).toBeNull()
+    expect(reg.issue(runCtx({ sessionId: 'ses_fixed' }), { stablePerSession: true })).not.toBe(t1)
+  })
 })

+ 10 - 0
docs/design/03-providers.md

@@ -130,6 +130,16 @@ Thread 参数:`approvalPolicy = on-request`、`cwd = workspaceDir`,沙箱使
 - **用量**:`thread/tokenUsage/updated` 给出线程累计与最近一次增量,本次 Run 用量 = 最新累计 −(首次通知的累计 − 首次增量)
 - **登录**:`account/login/start { type: "chatgptDeviceCode" }` 返回网址和短码,管理员在 OpenAI 页面输入,完成后收到 `account/login/completed`;AgentServer 不接触凭证。终端方式:`agent-codex login --device-auth`
 - **工具**:LibreOffice 在 Codex 沙箱中同样无法运行(禁止 Unix socket),`convert_document` / `recalc_workbook` / `web_fetch` / `kb_list_roots` 由 MCP Gateway 提供,与 Claude 共用(08)
+- **功能基线**:服务账号登录 ChatGPT 后,Codex 会自动安装 `openai-curated-remote` 中的远程插件,例如 Google Drive、Gmail、Outlook、Teams、Pages、Sites、Work Pets。这些插件会带来插件技能、应用连接器,以及 `request_plugin_install` 安装建议,调用方可能借此访问账号绑定的个人数据。因此每次对话的 config 都覆盖为:
+  - `features`:`plugins` / `remote_plugin` / `apps` / `tool_suggest` / `skill_mcp_dependency_install` / `multi_agent` / `browser_use` / `computer_use` 全部为 false
+  - `apps._default.enabled = false`
+  - 已验证:覆盖后对话中只剩 AgentServer 提供的技能与 MCP Gateway
+  - 遗留问题:`collaboration.*`(子代理)工具在关闭 `multi_agent` 后仍然出现,子代理继承同一沙箱,待确认对应开关
+- **网关令牌**:Codex 的会话在 app-server 中保持加载,`thread/resume` 不会更新已建立的 MCP 连接的请求头,所以 Codex 每个会话使用固定的令牌字符串:
+  - 每次 Run 开始时绑定到当前 Run,结束后失效
+  - 会话删除时清除
+  - 以前每个 Run 换新令牌,导致同一会话第二轮起网关调用返回 401
+- **已知限制**:同理,已加载会话的沙箱权限配置也不会因 resume 更新(`thread/unsubscribe` 不会卸载会话,`turn/start` 没有权限覆盖项)。会话进行中修改 Profile 的文件访问策略,要等新会话或服务重启后才对 Codex 命令生效;网关工具每次 Run 都使用最新策略
 
 ## 4. 能力差异与对齐