Skip to content

Commit 44e4330

Browse files
committed
docs: explain env conflict hard guard
Replace the previous 'silent strip' description in README/README-CN and docs/settings-merge-strategy.md with the new hard-guard behavior: ccc never modifies settings.json's env, and aborts with a clear error listing offending keys (no values) and the fix path. Also commit docs/discuss-20260519-env-priority.md — the empirical investigation log behind the decision (three controlled isolated-env tests verifying that Claude Code's settings.json env strictly overrides the process env passed via syscall.Exec).
1 parent c41ca9d commit 44e4330

4 files changed

Lines changed: 221 additions & 45 deletions

File tree

README-CN.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,25 @@ ccc validate --all
104104
- ✅ 用户安装的插件(`enabledPlugins`)- 不会被覆盖
105105
- ✅ 手动编辑的 `settings.json`(权限、沙箱等)- 完全保留
106106
- ✅ 用户配置的其他 hooks(PreToolUse、SessionStart 等)- 被尊重
107-
- ✅ 用户在 `settings.json` 中手动设置的环境变量 - 不会被删除(除了冲突,见下文)
107+
- ✅ 用户在 `settings.json` 中手动设置的环境变量 - ccc 不会修改
108108

109109
#### 管理的内容
110110

111111
- 🤖 Supervisor Stop hook - 自动添加/确保
112-
- 🧹 环境变量冲突 - `ANTHROPIC_*``CLAUDE_*` 和提供商 env 键从 `settings.json` 中移除以避免歧义
113112
- ⚙️ Hook 执行标志 - `disableAllHooks``allowManagedHooksOnly` 设置为 `false` 以确保 hooks 能正常工作
114113

114+
#### 环境变量冲突(硬守卫)
115+
116+
Claude Code 的 `settings.json` `env` 字段会**覆盖**ccc 启动 claude 时传入的环境变量(实测已验证)。如果 `settings.json` 中存在会遮蔽 provider env 的 key,切换 provider 会静默失效(用错 base_url / token / model)。
117+
118+
**当检测到此类冲突时,ccc 会拒绝启动 claude,`ccc validate` 同样会被拒绝。** ccc 不会静默修改你的文件,而是会打印冲突的 key(不打印 value,避免泄露密钥)并告诉你如何修复。**ccc 不会修改你 `settings.json``env` 字段。**
119+
120+
满足以下任一条件的 key 视为冲突:
121+
-`ANTHROPIC_``CLAUDE_` 开头,****
122+
- 与 ccc.json 中 base / provider `env` 定义的任何 key 同名。
123+
124+
**修复方法**:从 `~/.claude/settings.json``env` 中删除这些 key,并把 provider 相关配置改到 `~/.claude/ccc.json``providers.<name>.env` 中。
125+
115126
#### 工作原理
116127

117128
当你运行 `ccc` 时:

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,25 @@ Config file location, default: `~/.claude/ccc.json`
161161
- ✅ User-installed plugins (`enabledPlugins`) - never overwritten
162162
- ✅ Manual `settings.json` edits (permissions, sandbox, etc.) - fully preserved
163163
- ✅ User-configured hooks (PreToolUse, SessionStart, etc.) - respected
164-
- ✅ Environment variables you manually set in settings.json - not removed (except for conflicts, see below)
164+
- ✅ Environment variables you manually set in settings.json - ccc never edits them
165165

166166
#### What Gets Managed
167167

168168
- 🤖 Supervisor Stop hook - automatically added/ensured
169-
- 🧹 Environment variable conflicts - `ANTHROPIC_*`, `CLAUDE_*`, and provider env keys are removed from `settings.json` to avoid ambiguity
170169
- ⚙️ Hook execution flags - `disableAllHooks` and `allowManagedHooksOnly` set to `false` to ensure hooks work
171170

171+
#### Environment Variable Conflicts (Hard Guard)
172+
173+
Claude Code's `settings.json` `env` field **overrides** environment variables passed by ccc when launching claude (empirically verified). If `settings.json` contains keys that would shadow the provider env, switching providers silently fails (wrong base_url / token / model).
174+
175+
**ccc refuses to start claude — and refuses to run `ccc validate` — when it detects such conflicts.** Instead of silently editing your file, ccc prints the offending keys (without their values, to avoid leaking secrets) and tells you how to fix it. ccc never modifies the `env` field of your `settings.json`.
176+
177+
A key is considered conflicting if it:
178+
- starts with `ANTHROPIC_` or `CLAUDE_`, **or**
179+
- collides with any key defined in ccc.json's base / provider `env`.
180+
181+
**How to fix:** remove those keys from `~/.claude/settings.json`'s `env` and move provider-related configuration into `providers.<name>.env` in `~/.claude/ccc.json`.
182+
172183
#### How It Works
173184

174185
When you run `ccc`:
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# 讨论:env 优先级问题排查(PR #79
2+
3+
日期:2026-05-19
4+
话题:核实 ccc 切换 provider 时 env 的生效优先级,评估 PR #79 的方案是否安全。
5+
触发:https://github.com/guyskk/claude-code-supervisor/pull/79
6+
7+
> 本文件 Append-only,只追加不覆盖。
8+
9+
---
10+
11+
## 第 1 轮:背景探索与关键发现
12+
13+
### 1.1 PR #79 是什么
14+
15+
- PR #79 标题:`Preserve user env settings during provider switch`
16+
- 分支:`fix/preserve-user-env-in-settings`,head = `a251cbf`
17+
- 状态:**OPEN,CONFLICTING / DIRTY**(与 main 冲突)
18+
- 目标:解决"切换 provider 时把用户在 settings.json 里手动配置的 env 抹掉"的问题(Issue #74 类问题)。
19+
20+
PR #79 的代码改动(针对它的 base):
21+
1. `CleanEnvInSettings`:从「移除 `ANTHROPIC_*`/`CLAUDE_*` 前缀 + provider 同名 key」收窄为「只移除 provider 同名 key」,即**保留用户的 `ANTHROPIC_*`/`CLAUDE_*` 自定义 env**
22+
2. `provider.go`:删除 `delete(settingsWithHook, "env")`,即**不再清空 settings.json 的 env,让合并后的 env 留在 settings.json**
23+
24+
### 1.2 关键发现:PR #79 已与 main 分叉,且 main 已用另一套方案修复
25+
26+
- `git merge-base main pr79` = `8f0bf0c`(PR #73 合并点)。
27+
- PR #79`8f0bf0c` 拉出,但 main 之后已经走了 **PR #76(commit `c71511a` "fix: preserve user-defined env in settings.json")**,针对**同一个问题**做了**另一套修复**
28+
- 因此 PR #79 与 main 是**针对同一问题的两个独立方案**,PR #79 是陈旧的、与已合并的 #76 冲突的备选方案。
29+
- PR #79 diff 里还在改 `CleanEnvInSettings`,但 main 上该函数**已被 #76 删除**——这就是冲突来源。
30+
31+
### 1.3 当前 main(已含 #76)的 env 处理逻辑(实际代码确认)
32+
33+
`internal/provider/provider.go` `SwitchWithHook()`(行 37-121):
34+
- 分别提取三个来源的 env:`userEnvMap`(settings.json)、`baseEnvMap`(ccc.json settings)、`providerEnvMap`(ccc.json provider)。
35+
- `managedEnvKeys` = base env keys + provider env keys。
36+
- `MergeWithPriority(base, provider, user)` 深合并(user > provider > base)。
37+
- `EnsureStopHook` 注入 Supervisor Stop hook。
38+
- **`delete(settingsWithHook, "env")`** 然后写回 `FilterUserEnvForSettings(userEnvMap, managedEnvKeys)`
39+
- 子进程 env = `MergeEnvMaps(baseEnvMap, providerEnvMap)`**只有 base + provider,不含 user env**)。
40+
41+
`internal/config/config.go` `FilterUserEnvForSettings()`(行 256-276):
42+
- 写入 settings.json 的 env:跳过 `managedEnvKeys`,并且**跳过所有 `ANTHROPIC_*` / `CLAUDE_*` 前缀 key**
43+
- 结论:当前 main 下,**settings.json 的 env 永远不含 `ANTHROPIC_*`/`CLAUDE_*`**,因此 settings.json 与 provider env 不可能在这些 key 上冲突。
44+
45+
`internal/cli/exec.go` `runClaude()`(行 129-151):
46+
- `env := os.Environ()` 复制 ccc 进程环境。
47+
- 过滤掉所有 `CLAUDE_*` / `ANTHROPIC_*` 前缀的进程 env(保证 provider 优先)。
48+
- 追加 `switchResult.EnvVars`(= base + provider env)。
49+
- `syscall.Exec(claudePath, args, env)` 用该环境替换为 claude 进程。
50+
51+
### 1.4 待验证的核心未知
52+
53+
官方文档 `docs/claude-code-settings.md` 说明了 settings 作用域优先级(managed > user > project),但**没有说明 settings.json 的 `env` 字段与「claude 进程实际继承的 OS 环境变量」谁优先**
54+
55+
这是决定 PR #79 方案是否安全的关键问题:
56+
-**settings.json `env` 覆盖进程 env**:PR #79 保留用户旧的 `ANTHROPIC_BASE_URL` 在 settings.json 里,会**压过命令行传入的 provider env**,导致**切换 provider 失效**(用错 base url / token)。这是严重 bug。
57+
-**进程 env 覆盖 settings.json `env`**:PR #79 相对安全,provider env 仍生效,settings.json 里的旧 env 只在 provider 未定义该 key 时兜底。
58+
59+
**下一步:用 claude 实测这个优先级。**
60+
61+
---
62+
63+
## 第 2 轮:实测验证(claude 2.1.144,隔离环境 CLAUDE_CONFIG_DIR=./tmp/claude-home)
64+
65+
三个实验,settings.json 端口/值 vs 进程 env 端口/值,互为对照:
66+
67+
### 实验 A — 中性变量,经 Bash 工具观测
68+
- settings.json: `CCC_PRECEDENCE_TEST=VALUE_FROM_SETTINGS_JSON`
69+
- 进程 env: `CCC_PRECEDENCE_TEST=VALUE_FROM_PROCESS_ENV`
70+
- `claude -p "printenv CCC_PRECEDENCE_TEST"` 输出:**`VALUE_FROM_SETTINGS_JSON`**
71+
- → settings.json env 覆盖进程 env。
72+
73+
### 实验 B(决定性)— ANTHROPIC_BASE_URL,观测 claude 真实 API 连接目标
74+
- settings.json: `ANTHROPIC_BASE_URL=http://127.0.0.1:19991`
75+
- 进程 env: `ANTHROPIC_BASE_URL=http://127.0.0.1:19992`
76+
- 双端口监听器结果:**`HIT port=19991 (SETTINGS_JSON)`**
77+
- → 连 claude 自己发 API 用的 `ANTHROPIC_BASE_URL`,settings.json 也覆盖进程 env。
78+
79+
### 实验 C — settings.json 无 env(模拟当前 main 经 FilterUserEnvForSettings 剥离后的状态)
80+
- settings.json: 无 env
81+
- 进程 env: `ANTHROPIC_BASE_URL=http://127.0.0.1:19992`
82+
- 结果:**`HIT port=19992 (PROCESS_ENV)`**
83+
- → settings.json 不含该 key 时,进程 env(= ccc 传入的 provider env)才生效。
84+
85+
### 实测结论(确定)
86+
87+
**Claude Code 中:settings.json 的 `env` 严格覆盖 claude 进程继承的 OS 环境变量。**
88+
ccc 用 `syscall.Exec` 传入的 provider env 属于"进程环境变量"层级,**优先级低于 settings.json 的 env**
89+
90+
### 对三个问题的回答
91+
92+
1. **env 优先级**`settings.json.env` > 进程 env(ccc 命令行传入的 provider env)。仅当 settings.json 不含该 key 时,进程 env 兜底生效。
93+
2. **哪里的 env 生效**:settings.json 里有 `ANTHROPIC_BASE_URL` 就用它;没有才用 ccc 传入的 provider 值。
94+
3. **启动时不清除 settings.json 的 env 是否有问题****是,严重问题**。因 settings.json env 压过进程 env,ccc 靠进程 env 传 provider 配置会被 settings.json 里残留的旧 `ANTHROPIC_*` 覆盖 → **切换 provider 静默失效**(用错 base_url / token / model)。
95+
96+
### #76(当前 main)与 #79 的判断
97+
98+
- **当前 main(#76)方案正确**`FilterUserEnvForSettings` 把所有 `ANTHROPIC_*`/`CLAUDE_*` 及 base/provider managed key 一律从 settings.json 剔除 → settings.json 永不含这些 key → provider env 经进程 env 必然生效(实验 C 证明)。同时保留用户非冲突、非 `ANTHROPIC_*`/`CLAUDE_*` 的自定义 env(实验思路同 A,那类 key 会被保留并生效,正是用户想要的)。
99+
- **PR #79 方案不安全,不应合并**
100+
- 它只移除"与当前 provider 同名的 key",**保留用户的 `ANTHROPIC_*`/`CLAUDE_*`**;并删除 `delete(settingsWithHook,"env")`
101+
- 后果:provider 未覆盖的 `ANTHROPIC_*`(如 `ANTHROPIC_MODEL``ANTHROPIC_SMALL_FAST_MODEL`)或切换 provider 后 settings.json 残留的旧值,会留在 settings.json 并**覆盖**命令行传入的 provider env → 切 provider 失效 / 模型错乱。
102+
- 附带:provider token 被持久化写入 settings.json 文件(安全 & 陈旧问题)。
103+
- PR #79 想解决的真实诉求(用户手动配的非 provider env 不该被抹掉)**已被 #76 满足**
104+
- **建议**:PR #79 关闭(其诉求已由 #76 覆盖),不需要再改代码;若发现 #76 仍有未覆盖边界,应基于 main 现状重新评估,而非合并 #79
105+
106+
---
107+
108+
## 第 3 轮:用户决策与"迁移 + 提示"方案设计
109+
110+
### 用户决策
111+
112+
1. **PR #79 已关闭**(已执行 `gh pr close 79`,附实测说明评论)。
113+
2. 用户认可"#76 会丢失用户手动配的 `ANTHROPIC_*`/`CLAUDE_*`"是真实问题,要求:把这些 key **迁移到 ccc.json**(不丢失),并且**打断用户给出提示**(不静默),更稳妥。
114+
115+
### #76 现状复核(确认丢失问题真实存在)
116+
117+
`FilterUserEnvForSettings`(config.go:256)对 `managedEnvKeys` 命中 或 `ANTHROPIC_*`/`CLAUDE_*` 前缀的 key 一律 `continue` → 既不写回 settings.json,也不进子进程 env(子进程 env = `MergeEnvMaps(base, provider)`,不含 user)。结论:用户手动配的全局 `CLAUDE_CODE_*` 等,跑一次 ccc 后**永久丢失且失效**。迁移方案方向正确。
118+
119+
### 现有提示/迁移机制(用于风格对齐)
120+
121+
- `internal/migration/migration.go``GetUserInputFunc`(stdin `bufio.Reader`,测试可覆盖)、`PromptUser()``[y/N]`)、`MigrateFromSettings()`
122+
- 触发点 `internal/cli/cli.go:280-297`**仅在首次运行(ccc.json 不存在、`config.Load()` 报错)** 时提示迁移,把整个 settings.json 转成 ccc.json 的 `default` provider。
123+
- 这与本需求不同:本需求是**每次运行**都要检测 settings.json 里是否残留 `ANTHROPIC_*`/`CLAUDE_*`(ccc.json 已存在的常规场景),需新增独立的检测+提示+迁移逻辑,挂在启动路径(`runClaude` / `SwitchWithHook` 之前)。
124+
125+
### 提示方案设计(待对齐)
126+
127+
**触发条件**:启动 claude 前,`LoadSettings()` 得到的 user settings.json `env` 中存在任何 `ANTHROPIC_*`/`CLAUDE_*` 或命中 `managedEnvKeys` 的 key。
128+
129+
**交互内容**:列出检测到的这些 key,说明 WHY(settings.json 的 env 会覆盖 ccc 经命令行传入的 provider env,导致切换 provider 失效,已实测证明),给出迁移计划,`[Y/n]` 确认。
130+
131+
**迁移目标分类**(仍建议区分,避免凭据串台):
132+
- 凭据/端点/模型类(黑名单:`ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY`/`ANTHROPIC_MODEL`/`ANTHROPIC_SMALL_FAST_MODEL`):**不进 base**(进 base 会被所有 provider 继承,旧端点/模型串台/陈旧)。提示建议用户改用 provider 配置;ccc 这里仅从 settings.json 移除。
133+
- 其余 `ANTHROPIC_*`/`CLAUDE_*` 行为开关类:迁入 `ccc.json.settings.env`(base 模板),既不丢失又继续生效,且 settings.json 仍被清空(实验 C 保证切换正确)。
134+
135+
**幂等**:迁移后 settings.json 不再含这些 key → 后续运行不再触发提示(除非用户又手动加回,此时再次提示,因稀少不扰民)。
136+
137+
### 必须与用户对齐的关键设计点(阻塞项)
138+
139+
1. **非交互场景兜底**:supervisor 模式会重入 ccc(`CCC_SUPERVISOR_ID` 已设)、`ccc -p`、CI、stdin 非 TTY —— **绝不能在这些路径上阻塞等待输入**。兜底策略二选一:
140+
- (a) 回退到 #76 行为(剥离丢弃,切换仍正确,但丢全局偏好);
141+
- (b) 静默执行同样的"选择性迁移"+ stderr 一行提示(不丢失,推荐)。
142+
2. **用户拒绝(选 n)时的行为**:(a) 中止 ccc 不启动 claude,让用户自行处理;(b) 仍按 #76 安全剥离后继续启动(保证切换正确,但本次仍丢偏好)。
143+
3. **凭据黑名单**是否认可?`ANTHROPIC_MODEL`/`ANTHROPIC_SMALL_FAST_MODEL` 是否也算 provider 专属(不进 base)?
144+
4. **base 已存在同名 key**时,迁移是否以 settings.json 的值覆盖 base?
145+
5. `ccc validate` 路径是否也要触发提示?(建议不触发,仅启动路径触发)
146+
147+
---
148+
149+
## 第 4 轮:最终决策(方案大幅简化为"硬守卫,无交互")
150+
151+
用户多轮明确指示,最终方案锁定:
152+
153+
1. **不迁移、不帮用户改任何配置**。配置冲突是 ccc 解决不了的,交给用户自己处理。
154+
2. **不剥离、不静默继续**(这是对 #76 当前"静默 strip 后继续"行为的明确改变)。
155+
3. **无交互、无 y/N**:检测到冲突 → **直接报错提示并中止,不启动 claude**。交互与非交互行为一致。
156+
4. **报错信息必须包含**:冲突的具体 key 列表;原因(settings.json 的 `env` 覆盖 ccc 经命令行传入的 provider env,已实测证明,会导致切换 provider 失效);用户自己的修复方法(从 `~/.claude/settings.json``env` 删除这些 key;provider 相关配置改在 `~/.claude/ccc.json` 里配)。
157+
5. **作用范围**:正常启动路径 + `ccc validate` 都要做此检测。
158+
6. **检测判据**:settings.json 的 `env` 中存在 `ANTHROPIC_*` / `CLAUDE_*` 前缀 key,或与 base/provider env 同名的 managed key。非冲突的用户自定义 env(如 `MY_CUSTOM_VAR`)不受影响、允许保留。
159+
160+
### 行为变化说明(实施时需注意)
161+
162+
- #76`FilterUserEnvForSettings` 当前承担"静默剥离 `ANTHROPIC_*`/`CLAUDE_*`/managed key"的职责并继续运行;新方案下,命中即**中止**,不会走到剥离继续的逻辑。剥离逻辑是否保留/简化属计划阶段细节(因为中止后 settings.json 仍由用户保有原样,不再被 ccc 改写这些 key)。
163+
- 这是一个**破坏性行为变化**:原本能跑(静默丢配置)的场景,现在会被拦下要求用户先清理 settings.json。需在 README / docs/settings-merge-strategy.md 同步说明。
164+
165+
### 状态:需求与方案已完全对齐,无遗留疑问,待用户给出进入计划模式的指令。
166+
167+
---
168+
169+
## 第 5 轮:用户批准,进入计划模式
170+
171+
用户回复"好",确认方案。补充探索确认的集成点:
172+
- 启动路径:`internal/cli/exec.go` `runClaude()`,应在 `provider.SwitchWithHook()` 之前做守卫检测。
173+
- 校验路径:`internal/cli/cli.go` `runValidate()` 持有完整 `*config.Config`,应在调用 `validate.Run()` 之前做同样检测(`validate.Config` 接口仅暴露 `Providers()`/`CurrentProvider()`,故守卫放 `runValidate` 内最简单)。
174+
- 检测逻辑应作为 `internal/config` 的导出函数(如 `DetectEnvConflicts`),供两处复用,附错误信息格式化。
175+
- managedEnvKeys = base env keys ∪ provider env keys;判据 = `ANTHROPIC_*`/`CLAUDE_*` 前缀 ∨ 命中 managedEnvKeys。
176+
- TDD:检测函数有逻辑,需单测;两处集成点行为需测试。
177+
- 文档:README.md / README-CN.md / docs/settings-merge-strategy.md 同步破坏性变化。
178+
179+
进入计划模式编写详细 Plan。

0 commit comments

Comments
 (0)