Skip to content

fix(codex): preserve per-tool approval permissions across provider switch and MCP sync#3251

Open
KtzeAbyss wants to merge 1 commit into
farion1231:mainfrom
KtzeAbyss:fix/codex-mcp-tools-permission
Open

fix(codex): preserve per-tool approval permissions across provider switch and MCP sync#3251
KtzeAbyss wants to merge 1 commit into
farion1231:mainfrom
KtzeAbyss:fix/codex-mcp-tools-permission

Conversation

@KtzeAbyss
Copy link
Copy Markdown

@KtzeAbyss KtzeAbyss commented May 28, 2026

问题

当用户在 Codex CLI 中对某个 MCP 工具点击"始终允许"时,Codex 会向 ~/.codex/config.toml 写入:

[mcp_servers.<server_id>.tools.<tool_name>]
approval_mode = "approve"

但用户在 cc-switch 中切换供应商(或保存通用配置)后,这条权限被自动删除。即使放进"编辑通用配置"也无济于事——下一次同步仍会清掉。

根因

切换供应商触发两步连续覆盖,两步都会抹掉 tools.*

  1. Provider live 写入:provider 的 stored config 整张覆盖 ~/.codex/config.toml,而 stored config 不带 runtime 字段(cc-switch 数据库不存这种字段),这一步先抹掉。
  2. MCP syncMcpService 用 cc-switch 构造的服务器规范表整张替换 [mcp_servers.<id>],再次抹除任何嵌套子表。

修复

确立契约:cc-switch 只管它自己写入的字段;未知子表(典型为 tools.*)必须原样保留。 cc-switch 在 [mcp_servers.<id>] 下唯一会写为子表的键只有 envhttp_headers 及 compat 读取名 headers,其余子表一律视为非 cc-switch 管辖。

采用双层保护,对应根因两步:

  • Layer 1(mcp/codex.rs:sync 重写 [mcp_servers.<id>] 前快照非管辖子表,重写后写回。抽出 apply_single_server_to_doc / apply_enabled_servers_to_doc 两个纯函数便于单测。
  • Layer 2(codex_config.rsmcp/codex.rs::merge_codex_runtime_subtables:在 provider 驱动的 live 写入收口点 write_codex_live_for_provider 中,最终写入前从旧 live 读出未知子表合并进新文本。一处覆盖官方/三方两个写入分支;走裸 write_codex_live_atomic 的 byte-for-byte restore 路径不接入,保持精确还原语义。

两层缺一不可:只补 Layer 1 挡不住第 1 步覆盖;只补 Layer 2 挡不住后续 MCP sync。

Layer 2 合并语义(细节)

  • 逐子键深度合并:旧 live 与新文本同时存在 tools 子表时,按工具逐项合并而非整体取舍——旧 tools.search 与新 tools.read 都保留;同名工具叶子键冲突时新文本优先。
  • 不为新文本中不存在的 server 凭空建父表:provider 切换路径之后不再跑 MCP sync,无法补全 command/url,因此新文本不含的 server 其孤儿 tools.* 直接丢弃,避免写出 Codex 无法加载的残缺 server。

不在本 PR 范围

  • remove_server_from_codex:remove 语义为"该 server 不再存在",权限随之失效符合直觉。
  • 非 cc-switch 管理的 MCP 服务器sync_enabled_to_codex 会清掉不在 enabled 列表中的 server——pre-existing 行为,本 PR 不调整。
  • byte-for-byte restore 路径:裸 write_codex_live_atomic / LiveSnapshot::restore 保持原样,确保备份/回滚精确还原。

测试

mcp::codex::tests 共 14 个纯函数用例(不碰文件系统):

Layer 1(MCP sync 边界)

  • sync_single_preserves_tools_permission_subtable
  • sync_single_overwrites_managed_env_subtable
  • sync_single_overwrites_managed_http_headers_subtable
  • sync_enabled_preserves_tools_for_enabled_server
  • sync_enabled_drops_mcp_servers_when_empty(pre-existing 行为)
  • sync_enabled_drops_unmentioned_server_including_its_tools(pre-existing 行为)
  • sync_single_handles_empty_doc

Layer 2(Codex live 写入边界)

  • merge_runtime_preserves_tools_when_new_text_has_server_without_tools主复现路径
  • merge_runtime_merges_tools_per_tool_not_per_parent(逐工具合并)
  • merge_runtime_drops_orphan_tools_when_new_lacks_server(不建残缺父表)
  • merge_runtime_new_wins_when_key_collides
  • merge_runtime_skips_managed_subtables(覆盖 env / http_headers / compat headers
  • merge_runtime_returns_unchanged_when_old_has_no_mcp_servers
  • merge_runtime_returns_unchanged_when_old_unparseable

复现

  1. 在 cc-switch 注册 MCP server ace-tool-rs,启用 Codex
  2. 切换 Codex 供应商,确认 ~/.codex/config.toml 写入 [mcp_servers.ace-tool-rs]
  3. 启动 Codex,对某个工具点击"始终允许"
  4. 确认 [mcp_servers.ace-tool-rs.tools.<name>] approval_mode = "approve" 写入文件
  5. 在 cc-switch 中切换供应商
  6. 旧行为:tools.* 消失;新行为:保留

@farion1231
Copy link
Copy Markdown
Owner

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c5a6d41e06

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/mcp/codex.rs Outdated
Comment thread src-tauri/src/mcp/codex.rs Outdated
…itch and MCP sync

Codex CLI writes [mcp_servers.<id>.tools.<tool>] approval entries to
~/.codex/config.toml when the user clicks "always allow". Switching
providers (or saving common config) wiped those entries because the
write path overwrites config.toml twice:

1. Provider live write: write_codex_live_with_catalog ->
   write_codex_live_for_provider -> write_text_file fully overwrites
   config.toml. The new provider's stored config has no tools.*, so the
   runtime permissions are lost in this first step.
2. MCP sync: sync_single_server_to_codex / sync_enabled_to_codex
   replace the entire [mcp_servers.<id>] subtable, dropping any nested
   subtables a second time.

Establish the contract: cc-switch only owns fields it writes itself;
unknown subtables (typically tools.*) must be preserved verbatim. The
only subtable keys cc-switch writes under [mcp_servers.<id>] are env,
http_headers, and the compat read-only headers.

Two-layer fix matching the two overwrite steps:
- Layer 1 (mcp/codex.rs): before rewriting [mcp_servers.<id>],
  snapshot non-managed subtables and restore them afterward. Extract
  apply_single_server_to_doc / apply_enabled_servers_to_doc pure
  functions for unit testing.
- Layer 2 (codex_config.rs): in
  write_codex_live_atomic_with_stable_provider and
  write_codex_live_config_atomic_with_stable_provider, read the old
  live config before writing and merge any unknown subtables into the
  new text. write_codex_live_atomic (byte-for-byte restore path) and
  LiveSnapshot::restore are intentionally not touched so backup/restore
  semantics stay exact.

remove_server_from_codex is intentionally unchanged: when the server
itself is removed, permission entries becoming stale matches user
intent, and an empty parent [mcp_servers.<id>] would make Codex CLI
complain about a server with no command/url.

Includes 13 pure-function unit tests covering Layer 1 (managed vs
unmanaged subtable handling, enabled-empty drop, pre-existing
"unmentioned server removed" behavior) and Layer 2 (main repro path
where new text lacks mcp_servers, managed-subtable skipping for env /
http_headers / compat headers, new-text-wins collision, unparseable
old text best-effort, runtime-only parent creation).
@KtzeAbyss KtzeAbyss force-pushed the fix/codex-mcp-tools-permission branch from c5a6d41 to 6b02941 Compare May 29, 2026 05:29
@KtzeAbyss
Copy link
Copy Markdown
Author

针对自动审查提出的两条建议(P1/P2)均已采纳修复,详见对应行内线程的回复。同时本分支已 rebase 到最新 main 解决冲突。

补充一处实现说明:上游 refactor(codex): stop force-rewriting user's model_provider field 等改动删除了原先挂 Layer 2 的 write_codex_*_atomic_with_stable_provider 两个函数,因此 Layer 2 现改挂到新写入链路的唯一收口点 write_codex_live_for_provider——一处同时覆盖官方/三方两个写入分支,且不影响走裸 write_codex_live_atomic 的 byte-for-byte restore 路径。

本地 240 个 codex 相关测试全部通过。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants