配置系统
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
10-config-system - 章节摘要:沿一次项目配置的合并路径,理解远程、全局、项目、内联与托管来源如何经过替换、校验、规范化和 provenance 保留,最终影响 agent、permission 与 plugin。
- 教程版本:
v1.18.16 - 源码基线:
a3647eb025c7615159d417dcc49fc39fdaeba65b - 章节元数据:/versions/v1-18-16/data/chapters.json
- 源码映射:/versions/v1-18-16/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/config/config.tspackages/opencode/src/config/agent.tspackages/core/src/v1/config/permission.tspackages/opencode/src/config/plugin.tspackages/opencode/src/project/bootstrap.ts
源码基线:
v1.18.16(提交a3647eb025c7)。本章中的流程是根据源码整理出的典型路径,不是一次真实运行日志。
0. 本章学习目标
Section titled “0. 本章学习目标”读完本章,你应该能:
- 画出远程、全局、项目、配置目录、内联内容和托管配置的合并顺序。
- 解释为什么“后加载者覆盖先加载者”还不足以描述 OpenCode 的配置语义。
- 沿着一个项目配置,追到
agent、permission和plugin的规范化结果。 - 区分配置文件的来源、最终值和运行时初始化这三件事。
- 为自己的 agent 设计一个可验证、可追溯的配置管线。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 配置系统不是“读一个 JSON”,而是把多个来源按明确顺序加载、替换变量、校验、规范化和深度合并,最后以实例级状态提供给 agent、provider、tool 与 plugin。
本章的中心问题是:当两个地方都配置了同一个 agent 或权限时,运行时究竟相信谁,又怎样保留足够的来源信息?
v1.18.16 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”- permission、agent、plugin 等公共配置 schema 已下沉到
packages/core/src/v1/config/;packages/opencode/src/config/聚焦发现、解析、合并与运行时加载。 loadInstanceState仍按来源逐层合并,但插件来源被单独保存在plugin_origins,相对路径在尚知道声明文件时就解析。- 最新版额外处理 remote well-known 配置和 plugin scope;因此“最终值”与“值来自哪里”仍必须分开建模。
实例配置合并与 plugin provenance
packages/opencode/src/config/config.ts:314-430
来源顺序、插件 scope 与目录扫描在同一加载管线中汇合。
314 const loadInstanceState = Effect.fn("Config.loadInstanceState")(Effect 异步工作流。315 function* (ctx: InstanceContext) {定义一段可复用逻辑。316 const auth = yield* authSvc.all().pipe(Effect.orDie)Effect 异步工作流。317318 let result: Info = {}319 const authEnv: Record<string, string> = {}320 const consoleManagedProviders = new Set<string>()选择模型或 provider。321 let activeOrgName: string | undefined322323 const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {Effect 异步工作流。324 if (source.startsWith("http://") || source.startsWith("https://")) return "global"按条件进入分支。325 if (source === "OPENCODE_CONFIG_CONTENT") return "local"按条件进入分支。326 if (containsPath(source, ctx)) return "local"按条件进入分支。327 return "global"返回给上一层。328 })329330 const mergePluginOrigins = Effect.fnUntraced(function* (调用插件扩展点。331 source: string,332 // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step333 // is attached.334 list: ConfigPluginV1.Spec[] | undefined,调用插件扩展点。335 // Scope can be inferred from the source path, but some callers already know whether the config should336 // behave as global or local and can pass that explicitly.337 kind?: ConfigPlugin.Scope,调用插件扩展点。338 ) {339 if (!list?.length) return按条件进入分支。340 const hit = kind ?? (yield* pluginScopeForSource(source))等待 Effect 结果。341 // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while342 // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.343 const plugins = ConfigPlugin.deduplicatePluginOrigins([调用插件扩展点。344 ...(result.plugin_origins ?? []),345 ...list.map((spec) => ({ spec, source, scope: hit })),346 ])347 result.plugin = plugins.map((item) => item.spec)348 result.plugin_origins = plugins349 })350351 const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {调用插件扩展点。352 result = mergeConfigConcatArrays(result, next)353 return mergePluginOrigins(source, next.plugin, kind)调用插件扩展点。354 }355356 for (const [key, value] of Object.entries(auth)) {遍历集合。357 if (value.type === "wellknown") {按条件进入分支。358 const url = key.replace(/\/+$/, "")359 authEnv[value.key] = value.token360 const wellknownURL = `${url}/.well-known/opencode`361 yield* Effect.logDebug("fetching remote config", { url: wellknownURL })Effect 异步工作流。362 const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown, url)等待 Effect 结果。363 const remote = yield* Effect.promise(() =>Effect 异步工作流。364 substituteWellKnownRemoteConfig({365 value: wellknown.remote_config,366 dir: url,367 source: wellknownURL,368 env: authEnv,369 }),370 )371 const fetchedConfig = remote372 ? yield* Effect.gen(function* () {Effect 异步工作流。373 yield* Effect.logDebug("fetching remote config", { url: remote.url })Effect 异步工作流。374 const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json, url)定义并校验数据形状。375 if (isRecord(data) && isRecord(data.config)) return data.config按条件进入分支。376 if (isRecord(data)) return data按条件进入分支。377 return yield* Effect.die(Effect 异步工作流。378 new Error(`failed to decode remote config from ${remote.url}: expected object`),379 )380 })381 : {}382 const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)383 if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"读取运行配置。384 const source = wellknownURL385 const next = yield* loadConfig(等待 Effect 结果。386 JSON.stringify(remoteConfig),387 {388 dir: path.dirname(source),389 source,390 },391 authEnv,392 )393 yield* merge(source, next, "global")等待 Effect 结果。394 yield* Effect.logDebug("loaded remote config from well-known", { url })Effect 异步工作流。395 }396 }397398 const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()读写本地文件。399 yield* merge(Global.Path.config, global, "global")读写本地文件。400401 if (Flag.OPENCODE_CONFIG) {按条件进入分支。402 yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))等待 Effect 结果。403 yield* Effect.logDebug("loaded custom config", { path: Flag.OPENCODE_CONFIG })Effect 异步工作流。404 }405406 if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {按条件进入分支。407 for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {Effect 异步工作流。408 yield* merge(file, yield* loadFile(file, authEnv), "local")等待 Effect 结果。409 }410 }411412 result.agent = result.agent || {}413 result.mode = result.mode || {}414 result.plugin = result.plugin || []415416 const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)等待 Effect 结果。417418 if (Flag.OPENCODE_CONFIG_DIR) {按条件进入分支。419 yield* Effect.logDebug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })Effect 异步工作流。420 }421422 const deps: Fiber.Fiber<void>[] = []423424 for (const dir of directories) {遍历集合。425 if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {按条件进入分支。426 for (const file of ["opencode.json", "opencode.jsonc"]) {遍历集合。427 const source = path.join(dir, file)428 yield* Effect.logDebug(`loading config from ${source}`)Effect 异步工作流。429 yield* merge(source, yield* loadFile(source, authEnv))等待 Effect 结果。430 result.agent ??= {}
权限配置公共 schema
packages/core/src/v1/config/permission.ts:1-51
配置形状已从 runtime 目录下沉到 core/v1。
1export * as ConfigPermissionV1 from "./permission"对外暴露模块成员。23import { Schema, SchemaGetter } from "effect"引入需要的模块。45export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })定义并校验数据形状。6export type Action = Schema.Schema.Type<typeof Action>定义并校验数据形状。78export const Object = Schema.Record(Schema.String, Action).annotate({ identifier: "PermissionObjectConfig" })定义并校验数据形状。9export type Object = Schema.Schema.Type<typeof Object>定义并校验数据形状。1011export const Rule = Schema.Union([Action, Object]).annotate({ identifier: "PermissionRuleConfig" })定义并校验数据形状。12export type Rule = Schema.Schema.Type<typeof Rule>定义并校验数据形状。1314// Known permission keys get explicit types in the Effect schema for generated15// docs/types. Runtime config parsing uses Effect's `propertyOrder: "original"`16// parse option so user key order is preserved for permission precedence.17const InputObject = Schema.StructWithRest(定义并校验数据形状。18 Schema.Struct({定义并校验数据形状。19 read: Schema.optional(Rule),定义并校验数据形状。20 edit: Schema.optional(Rule),定义并校验数据形状。21 glob: Schema.optional(Rule),定义并校验数据形状。22 grep: Schema.optional(Rule),定义并校验数据形状。23 list: Schema.optional(Rule),定义并校验数据形状。24 bash: Schema.optional(Rule),定义并校验数据形状。25 task: Schema.optional(Rule),定义并校验数据形状。26 external_directory: Schema.optional(Rule),定义并校验数据形状。27 todowrite: Schema.optional(Action),定义并校验数据形状。28 question: Schema.optional(Action),定义并校验数据形状。29 webfetch: Schema.optional(Action),定义并校验数据形状。30 websearch: Schema.optional(Action),定义并校验数据形状。31 lsp: Schema.optional(Rule),定义并校验数据形状。32 doom_loop: Schema.optional(Action),定义并校验数据形状。33 skill: Schema.optional(Rule),定义并校验数据形状。34 }),35 [Schema.Record(Schema.String, Rule)],定义并校验数据形状。36)3738const InputSchema = Schema.Union([Action, InputObject])定义并校验数据形状。3940const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>定义并校验数据形状。41 typeof input === "string" ? { "*": input } : input4243export const Info = InputSchema.pipe(定义并校验数据形状。44 Schema.decodeTo(InputObject, {定义并校验数据形状。45 decode: SchemaGetter.transform(normalizeInput),46 encode: SchemaGetter.passthrough({ strict: false }),47 }),48).annotate({ identifier: "PermissionConfig" })49type _Info = Schema.Schema.Type<typeof InputObject>定义并校验数据形状。50export type Info = { -readonly [K in keyof _Info]: _Info[K] }定义数据结构约束。51
2. 为什么现在必须理解配置
Section titled “2. 为什么现在必须理解配置”前面几章已经讲过 tool、permission 和 provider。但如果你只读这些模块,会误以为它们的行为都写死在源码中。实际上:
model、provider决定模型入口;agent决定提示词、模型、步数和局部权限;permission决定工具是否直接执行、询问或拒绝;plugin、mcp、lsp、formatter决定运行时还会接入哪些能力。
因此,配置系统像一张控制面:它不执行 agent loop,却决定 loop 拿到什么零件。
3. 先画地图:来源、管线、消费者
Section titled “3. 先画地图:来源、管线、消费者”远程 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。
这里有三条边界:
- 来源层回答“值从哪里来”。
- 解析层回答“这个值是否合法、如何统一形状”。
- 消费层回答“agent/tool/plugin 如何使用最终值”。
不要把三层混成一个巨大的 loadConfig()。
4. 最小机制
Section titled “4. 最小机制”先忽略插件来源、缓存和兼容迁移,配置内核只有五步:
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。
“后者覆盖前者”有哪些例外
Section titled ““后者覆盖前者”有哪些例外”| 字段 | 合并行为 | 原因 |
|---|---|---|
| 普通对象字段 | mergeDeep,后来源覆盖冲突值 | 允许项目覆盖全局默认值 |
instructions | 合并、去重 | 多层指令通常需要累积 |
plugin | 按加载身份去重,并保留获胜来源 | 相对路径和安装作用域依赖来源 |
旧 tools 布尔表 | 转成 permission | 兼容旧配置,消费端只面对新模型 |
mode | 最终折叠进 agent | 兼容旧名称 |
instructions 的特殊规则在 packages/opencode/src/config/config.ts;旧字段归一化在 packages/opencode/src/config/config.ts。
5. 读源码前需要分清的三个概念
Section titled “5. 读源码前需要分清的三个概念”5.1 Schema 不是默认值仓库
Section titled “5.1 Schema 不是默认值仓库”Info 描述允许出现的字段和类型,例如 agent、provider、mcp、permission,见 packages/opencode/src/config/config.ts。它主要负责边界校验和生成可理解的契约,不代表每个字段都会在解析时补齐默认值。
5.2 规范化不是合并
Section titled “5.2 规范化不是合并”合并决定优先级;规范化把多种写法变成一种内部形状。例如 permission 可以写成一个动作字符串,也可以写成目标规则对象:
1{ "permission": "ask" }
会被解释成等价的:
1{ "permission": { "*": "ask" } }
证据在 packages/core/src/v1/config/permission.ts。v1.18.16 已把公共配置 schema 下沉到 core;runtime config loader 只消费该契约。
5.3 来源信息不是普通配置值
Section titled “5.3 来源信息不是普通配置值”plugin_origins 是合并过程中派生出的状态,不会作为用户配置写回。它把 plugin spec 与 source、scope 绑在一起,见 packages/opencode/src/config/config.ts 与 packages/opencode/src/config/plugin.ts。
6. 一条具体源码旅程:项目 agent、权限和插件怎样生效
Section titled “6. 一条具体源码旅程:项目 agent、权限和插件怎样生效”假设项目中的某个 opencode.jsonc 声明:
1{2 "agent": {3 "review": {4 "model": "anthropic/claude-sonnet",5 "steps": 8,6 "tools": { "write": false, "read": true }7 }8 },9 "plugin": ["./plugin/reviewer.ts"],10 "instructions": ["CONTRIBUTING.md"]11}
这只是教学输入;下面是源码证明的典型路径,不声称实际执行过这份配置。
第一步:找到项目配置
Section titled “第一步:找到项目配置”loadInstanceState 先合并全局和显式文件,再在未禁用项目配置时调用 ConfigPaths.files(...),逐个 loadFile,见 packages/opencode/src/config/config.ts。
这意味着项目配置处于全局配置之后,可以覆盖全局冲突值。
第二步:变量替换、解析、校验
Section titled “第二步:变量替换、解析、校验”loadFile 读到文本后进入 loadConfig:
ConfigVariable.substitute -> ConfigParse.jsonc -> ConfigParse.schema(Info, ...)对应 packages/opencode/src/config/config.ts。顺序很重要:如果变量替换后的结果破坏了 JSONC 或类型,错误应在配置边界暴露,而不是等到 provider/tool 使用时才爆炸。
第三步:相对 plugin 路径立刻绑定声明文件
Section titled “第三步:相对 plugin 路径立刻绑定声明文件”在仍然知道配置文件路径时,resolveLoadedPlugins 调用 ConfigPlugin.resolvePluginSpec,见 packages/opencode/src/config/config.ts。
./plugin/reviewer.ts 会相对“声明它的配置文件”解析为 file URL,而不是相对最终工作目录猜测,见 packages/opencode/src/config/plugin.ts。
这是本章最值得迁移的设计:位置敏感的值要在来源上下文尚未丢失时解析。
第四步:合并值,同时保留插件 provenance
Section titled “第四步:合并值,同时保留插件 provenance”普通字段走深度合并;instructions 累积去重。插件则附上来源和 global/local scope,再按插件加载身份去重,后出现的声明获胜,见:
packages/opencode/src/config/config.tspackages/opencode/src/config/plugin.ts
如果只保留最终字符串,后续代码就无法判断插件应在哪个目录安装、错误该指向哪个文件。
第五步:agent 配置收敛为统一内部形状
Section titled “第五步: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。
第六步:配置先于其他实例服务物化
Section titled “第六步:配置先于其他实例服务物化”项目 bootstrap 明确先 config.get(),再初始化 plugin;plugin 可能修改配置,所以又必须早于其他服务,见 packages/opencode/src/project/bootstrap.ts。
config.get() -> plugin.init() -> reference / lsp / format / file / watcher / vcs / snapshot / project配置因此不是“任意时刻读磁盘”,而是实例启动阶段的有序依赖。
7. 关键分支与失败边界
Section titled “7. 关键分支与失败边界”7.1 远程配置失败是硬失败还是软失败
Section titled “7.1 远程配置失败是硬失败还是软失败”well-known 远程配置请求非 2xx 时直接抛错,见 packages/opencode/src/config/config.ts。而活动组织配置的读取被 catch 后记录 debug 并继续,见 packages/opencode/src/config/config.ts。
这说明“远程配置”不是一个统一策略;其可靠性语义取决于来源。不要从一条路径推断全部远程来源。
7.2 全局配置使用无限 TTL 缓存
Section titled “7.2 全局配置使用无限 TTL 缓存”loadGlobal 被 cachedInvalidateWithTTL(..., Duration.infinity) 包装,更新全局配置后显式 invalidate,见 packages/opencode/src/config/config.ts、786-808。
取舍是:频繁读取便宜,但所有写入口必须维护失效规则。
7.3 MDM 托管配置最后覆盖
Section titled “7.3 MDM 托管配置最后覆盖”managed 目录和 macOS managed preferences 在本地、项目、内联与组织配置之后合并;源码注释明确说明 MDM “override everything”,见 packages/opencode/src/config/config.ts。
这是组织策略优先于个人偏好的产品选择,不是通用配置系统都必须如此。
7.4 配置目录会触发依赖准备
Section titled “7.4 配置目录会触发依赖准备”对每个配置目录,OpenCode 会确保 .gitignore,后台安装 @opencode-ai/plugin,并收集 fiber;插件真正加载前可以等待这些依赖,见 packages/opencode/src/config/config.ts、767-770。
所以配置加载包含副作用。测试这部分时,不能只断言最终 JSON,还要覆盖依赖准备与失败降级。
8. OpenCode 的选择:替代方案与取舍
Section titled “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. 可以带走的方法
Section titled “9. 可以带走的方法”方法一:把优先级写成可执行顺序
Section titled “方法一:把优先级写成可执行顺序”不要只在 README 写“项目覆盖全局”。让加载代码按优先级从低到高排列,并用冲突用例验证。
验证问题:同一字段分别出现在全局、项目、内联和托管配置时,测试能否指出最终值及获胜来源?
方法二:在来源消失前解析位置敏感值
Section titled “方法二:在来源消失前解析位置敏感值”相对路径、密钥引用、include 文件都依赖声明位置。读取文件后立刻绝对化或附上 provenance,不要等合并结束再猜。
验证问题:从另一个工作目录启动时,./plugin.ts 是否仍指向声明文件旁边?
方法三:兼容逻辑集中在边界
Section titled “方法三:兼容逻辑集中在边界”旧 tools、maxSteps、mode 在配置层收敛,消费端只读一种内部模型。
验证问题:删除兼容别名后,agent/tool 消费代码是否完全不需要改?如果需要,边界还没有收干净。
10. 费曼复述与练习
Section titled “10. 费曼复述与练习”先不用源码,用 90 秒回答:
- 为什么配置系统不是
JSON.parse? - 为什么插件列表不能像普通字符串数组一样合并?
- 为什么 MDM 配置放在最后?
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
Section titled “最后复盘:配置决定有哪些 OpenCode”这一章的最小结论是:
多个来源 -> 替换与校验 -> 有字段语义的合并 -> 兼容归一化 -> 实例级 Config.Info -> agent/provider/tool/plugin 消费配置层解决了“同一套 runtime 如何变成不同产品形态”。但配置完成后,用户到底从 CLI、TUI、Desktop 或 IDE 看到同一个 runtime?下一章会沿着一个 IDE 文件引用,追踪多种界面怎样共享后端而不复制 agent loop。