# 配置系统

> 源码基线：`v1.18.16`（提交 `a3647eb025c7`）。本章中的流程是根据源码整理出的典型路径，不是一次真实运行日志。

## 0. 本章学习目标

读完本章，你应该能：

- 画出远程、全局、项目、配置目录、内联内容和托管配置的合并顺序。
- 解释为什么“后加载者覆盖先加载者”还不足以描述 OpenCode 的配置语义。
- 沿着一个项目配置，追到 `agent`、`permission` 和 `plugin` 的规范化结果。
- 区分配置文件的来源、最终值和运行时初始化这三件事。
- 为自己的 agent 设计一个可验证、可追溯的配置管线。

## 1. 一句话讲明白

OpenCode 配置系统不是“读一个 JSON”，而是把多个来源按明确顺序加载、替换变量、校验、规范化和深度合并，最后以实例级状态提供给 agent、provider、tool 与 plugin。

本章的中心问题是：**当两个地方都配置了同一个 agent 或权限时，运行时究竟相信谁，又怎样保留足够的来源信息？**

## v1.18.16 相比旧基线改变了什么

- permission、agent、plugin 等公共配置 schema 已下沉到 `packages/core/src/v1/config/`；`packages/opencode/src/config/` 聚焦发现、解析、合并与运行时加载。
- `loadInstanceState` 仍按来源逐层合并，但插件来源被单独保存在 `plugin_origins`，相对路径在尚知道声明文件时就解析。
- 最新版额外处理 remote well-known 配置和 plugin scope；因此“最终值”与“值来自哪里”仍必须分开建模。

<!-- source-ref path="packages/opencode/src/config/config.ts" lines="314-430" title="实例配置合并与 plugin provenance" note="来源顺序、插件 scope 与目录扫描在同一加载管线中汇合。" -->

<!-- source-ref path="packages/core/src/v1/config/permission.ts" lines="1-51" title="权限配置公共 schema" note="配置形状已从 runtime 目录下沉到 core/v1。" -->

## 2. 为什么现在必须理解配置

前面几章已经讲过 tool、permission 和 provider。但如果你只读这些模块，会误以为它们的行为都写死在源码中。实际上：

- `model`、`provider` 决定模型入口；
- `agent` 决定提示词、模型、步数和局部权限；
- `permission` 决定工具是否直接执行、询问或拒绝；
- `plugin`、`mcp`、`lsp`、`formatter` 决定运行时还会接入哪些能力。

因此，配置系统像一张控制面：它不执行 agent loop，却决定 loop 拿到什么零件。

## 3. 先画地图：来源、管线、消费者

```text
远程 well-known
        |
全局 config.json -> opencode.json -> opencode.jsonc
        |
OPENCODE_CONFIG 指定文件
        |
项目路径上的 opencode 配置
        |
.opencode / OPENCODE_CONFIG_DIR 中的配置与 Markdown 条目
        |
OPENCODE_CONFIG_CONTENT
        |
组织配置 -> managed 目录 -> macOS MDM
        |
兼容字段与环境 flag 归一化
        v
     Config.Info
        |
        +--> Agent / Provider / Permission / Tool
        +--> Plugin / MCP / LSP / Formatter
        +--> UI 与 Server
```

顺序来自 `packages/opencode/src/config/config.ts`。同一实例的结果由 `InstanceState` 保存，`Config.get()` 读取的是合并后的 `Info`，见 `packages/opencode/src/config/config.ts`。

这里有三条边界：

1. **来源层**回答“值从哪里来”。
2. **解析层**回答“这个值是否合法、如何统一形状”。
3. **消费层**回答“agent/tool/plugin 如何使用最终值”。

不要把三层混成一个巨大的 `loadConfig()`。

## 4. 最小机制

先忽略插件来源、缓存和兼容迁移，配置内核只有五步：

```text
result = {}

for source in sourcesByPriority:
  text = read(source)
  expanded = substituteVariables(text)
  parsed = parseJsonc(expanded)
  valid = decodeSchema(parsed)
  result = deepMerge(result, valid)

return normalizeCompatibility(result)
```

OpenCode 的 `loadConfig` 承担变量替换、JSONC 解析和 Schema 校验，见 `packages/opencode/src/config/config.ts`；`merge` 在每次合并后补记 plugin 来源，见 `packages/opencode/src/config/config.ts`。

### “后者覆盖前者”有哪些例外

| 字段 | 合并行为 | 原因 |
|---|---|---|
| 普通对象字段 | `mergeDeep`，后来源覆盖冲突值 | 允许项目覆盖全局默认值 |
| `instructions` | 合并、去重 | 多层指令通常需要累积 |
| `plugin` | 按加载身份去重，并保留获胜来源 | 相对路径和安装作用域依赖来源 |
| 旧 `tools` 布尔表 | 转成 `permission` | 兼容旧配置，消费端只面对新模型 |
| `mode` | 最终折叠进 `agent` | 兼容旧名称 |

`instructions` 的特殊规则在 `packages/opencode/src/config/config.ts`；旧字段归一化在 `packages/opencode/src/config/config.ts`。

## 5. 读源码前需要分清的三个概念

### 5.1 Schema 不是默认值仓库

`Info` 描述允许出现的字段和类型，例如 `agent`、`provider`、`mcp`、`permission`，见 `packages/opencode/src/config/config.ts`。它主要负责边界校验和生成可理解的契约，不代表每个字段都会在解析时补齐默认值。

### 5.2 规范化不是合并

合并决定优先级；规范化把多种写法变成一种内部形状。例如 permission 可以写成一个动作字符串，也可以写成目标规则对象：

```json
{ "permission": "ask" }
```

会被解释成等价的：

```json
{ "permission": { "*": "ask" } }
```

证据在 `packages/core/src/v1/config/permission.ts`。`v1.18.16` 已把公共配置 schema 下沉到 core；runtime config loader 只消费该契约。

### 5.3 来源信息不是普通配置值

`plugin_origins` 是合并过程中派生出的状态，不会作为用户配置写回。它把 plugin spec 与 `source`、`scope` 绑在一起，见 `packages/opencode/src/config/config.ts` 与 `packages/opencode/src/config/plugin.ts`。

## 6. 一条具体源码旅程：项目 agent、权限和插件怎样生效

假设项目中的某个 `opencode.jsonc` 声明：

```jsonc
{
  "agent": {
    "review": {
      "model": "anthropic/claude-sonnet",
      "steps": 8,
      "tools": { "write": false, "read": true }
    }
  },
  "plugin": ["./plugin/reviewer.ts"],
  "instructions": ["CONTRIBUTING.md"]
}
```

这只是教学输入；下面是源码证明的典型路径，不声称实际执行过这份配置。

### 第一步：找到项目配置

`loadInstanceState` 先合并全局和显式文件，再在未禁用项目配置时调用 `ConfigPaths.files(...)`，逐个 `loadFile`，见 `packages/opencode/src/config/config.ts`。

这意味着项目配置处于全局配置之后，可以覆盖全局冲突值。

### 第二步：变量替换、解析、校验

`loadFile` 读到文本后进入 `loadConfig`：

```text
ConfigVariable.substitute
  -> ConfigParse.jsonc
  -> ConfigParse.schema(Info, ...)
```

对应 `packages/opencode/src/config/config.ts`。顺序很重要：如果变量替换后的结果破坏了 JSONC 或类型，错误应在配置边界暴露，而不是等到 provider/tool 使用时才爆炸。

### 第三步：相对 plugin 路径立刻绑定声明文件

在仍然知道配置文件路径时，`resolveLoadedPlugins` 调用 `ConfigPlugin.resolvePluginSpec`，见 `packages/opencode/src/config/config.ts`。

`./plugin/reviewer.ts` 会相对“声明它的配置文件”解析为 file URL，而不是相对最终工作目录猜测，见 `packages/opencode/src/config/plugin.ts`。

这是本章最值得迁移的设计：**位置敏感的值要在来源上下文尚未丢失时解析。**

### 第四步：合并值，同时保留插件 provenance

普通字段走深度合并；`instructions` 累积去重。插件则附上来源和 global/local scope，再按插件加载身份去重，后出现的声明获胜，见：

- `packages/opencode/src/config/config.ts`
- `packages/opencode/src/config/plugin.ts`

如果只保留最终字符串，后续代码就无法判断插件应在哪个目录安装、错误该指向哪个文件。

### 第五步：agent 配置收敛为统一内部形状

`ConfigAgent.Info` 接受 `model`、`prompt`、`steps`、`permission` 等字段。它还把：

- 未知的 provider 相关键收进 `options`；
- 旧 `tools` 布尔表翻译成 `permission`；
- `maxSteps` 合并到 `steps`。

对应 `packages/opencode/src/config/agent.ts`。例子里的 `write: false` 会折叠到 `permission.edit = "deny"`，因为 write/edit/patch 被视为同一编辑权限族，见 `packages/opencode/src/config/agent.ts`。

### 第六步：配置先于其他实例服务物化

项目 bootstrap 明确先 `config.get()`，再初始化 plugin；plugin 可能修改配置，所以又必须早于其他服务，见 `packages/opencode/src/project/bootstrap.ts`。

```text
config.get()
  -> plugin.init()
  -> reference / lsp / format / file / watcher / vcs / snapshot / project
```

配置因此不是“任意时刻读磁盘”，而是实例启动阶段的有序依赖。

## 7. 关键分支与失败边界

### 7.1 远程配置失败是硬失败还是软失败

well-known 远程配置请求非 2xx 时直接抛错，见 `packages/opencode/src/config/config.ts`。而活动组织配置的读取被 `catch` 后记录 debug 并继续，见 `packages/opencode/src/config/config.ts`。

这说明“远程配置”不是一个统一策略；其可靠性语义取决于来源。不要从一条路径推断全部远程来源。

### 7.2 全局配置使用无限 TTL 缓存

`loadGlobal` 被 `cachedInvalidateWithTTL(..., Duration.infinity)` 包装，更新全局配置后显式 invalidate，见 `packages/opencode/src/config/config.ts`、`786-808`。

取舍是：频繁读取便宜，但所有写入口必须维护失效规则。

### 7.3 MDM 托管配置最后覆盖

managed 目录和 macOS managed preferences 在本地、项目、内联与组织配置之后合并；源码注释明确说明 MDM “override everything”，见 `packages/opencode/src/config/config.ts`。

这是组织策略优先于个人偏好的产品选择，不是通用配置系统都必须如此。

### 7.4 配置目录会触发依赖准备

对每个配置目录，OpenCode 会确保 `.gitignore`，后台安装 `@opencode-ai/plugin`，并收集 fiber；插件真正加载前可以等待这些依赖，见 `packages/opencode/src/config/config.ts`、`767-770`。

所以配置加载包含副作用。测试这部分时，不能只断言最终 JSON，还要覆盖依赖准备与失败降级。

## 8. OpenCode 的选择：替代方案与取舍

| 选择 | 好处 | 代价 |
|---|---|---|
| 多来源深度合并 | 全局默认与项目定制可组合 | 优先级不透明时难排错 |
| Schema 边界校验 | 错误更早、SDK/文档契约更清晰 | 兼容旧字段需要额外 normalize |
| 保留 plugin provenance | 相对路径、作用域、诊断都可靠 | 最终状态不再只是纯 JSON |
| 实例级缓存 | 消费端读取稳定且便宜 | 更新后必须正确 invalidate |
| plugin 先于其他服务初始化 | 插件可参与配置和能力装配 | bootstrap 顺序形成真实耦合 |

Java 类比可以暂时把它看成 Spring `Environment` + `PropertySource` + Binder，但类比到此为止：OpenCode 还会扫描 Markdown agent、解析本地 plugin file URL，并在 Effect 实例状态中管理缓存与依赖 fiber。

## 9. 可以带走的方法

### 方法一：把优先级写成可执行顺序

不要只在 README 写“项目覆盖全局”。让加载代码按优先级从低到高排列，并用冲突用例验证。

验证问题：同一字段分别出现在全局、项目、内联和托管配置时，测试能否指出最终值及获胜来源？

### 方法二：在来源消失前解析位置敏感值

相对路径、密钥引用、include 文件都依赖声明位置。读取文件后立刻绝对化或附上 provenance，不要等合并结束再猜。

验证问题：从另一个工作目录启动时，`./plugin.ts` 是否仍指向声明文件旁边？

### 方法三：兼容逻辑集中在边界

旧 `tools`、`maxSteps`、`mode` 在配置层收敛，消费端只读一种内部模型。

验证问题：删除兼容别名后，agent/tool 消费代码是否完全不需要改？如果需要，边界还没有收干净。

## 10. 费曼复述与练习

先不用源码，用 90 秒回答：

1. 为什么配置系统不是 `JSON.parse`？
2. 为什么插件列表不能像普通字符串数组一样合并？
3. 为什么 MDM 配置放在最后？
4. `Config.get()` 与每次工具调用都读配置文件，有什么不同？

再做三步练习：

- **入门**：画出全局、项目、内联、托管四层的覆盖箭头。
- **进阶**：为你的 mini agent 写 `load -> substitute -> validate -> merge -> normalize` 伪代码。
- **源码追踪**：从 `packages/opencode/src/config/config.ts` 读到 `packages/opencode/src/config/agent.ts`，说明旧 `tools.write=false` 如何变成编辑拒绝规则。

如果你只能说“后面的覆盖前面的”，请回到第 4、6 节：你还漏掉了数组策略、provenance 和兼容归一化。

## 最后复盘：配置决定有哪些 OpenCode

这一章的最小结论是：

```text
多个来源
  -> 替换与校验
  -> 有字段语义的合并
  -> 兼容归一化
  -> 实例级 Config.Info
  -> agent/provider/tool/plugin 消费
```

配置层解决了“同一套 runtime 如何变成不同产品形态”。但配置完成后，用户到底从 CLI、TUI、Desktop 或 IDE 看到同一个 runtime？下一章会沿着一个 IDE 文件引用，追踪多种界面怎样共享后端而不复制 agent loop。
