跳转到内容

配置系统

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度较难
预计阅读45 分钟
  • 章节 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
  • packages/opencode/src/config/config.ts
  • packages/opencode/src/config/agent.ts
  • packages/core/src/v1/config/permission.ts
  • packages/opencode/src/config/plugin.ts
  • packages/opencode/src/project/bootstrap.ts

源码基线:v1.18.16(提交 a3647eb025c7)。本章中的流程是根据源码整理出的典型路径,不是一次真实运行日志。

读完本章,你应该能:

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

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

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

  • 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

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

  • modelprovider 决定模型入口;
  • agent 决定提示词、模型、步数和局部权限;
  • permission 决定工具是否直接执行、询问或拒绝;
  • pluginmcplspformatter 决定运行时还会接入哪些能力。

因此,配置系统像一张控制面:它不执行 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

这里有三条边界:

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

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

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

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.tsmerge 在每次合并后补记 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. 读源码前需要分清的三个概念

Section titled “5. 读源码前需要分清的三个概念”

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

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

1{ "permission": "ask" }

会被解释成等价的:

1{ "permission": { "*": "ask" } }

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

plugin_origins 是合并过程中派生出的状态,不会作为用户配置写回。它把 plugin spec 与 sourcescope 绑在一起,见 packages/opencode/src/config/config.tspackages/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}

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

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.ts
  • packages/opencode/src/config/plugin.ts

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

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

Section titled “第五步:agent 配置收敛为统一内部形状”

ConfigAgent.Info 接受 modelpromptstepspermission 等字段。它还把:

  • 未知的 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.1 远程配置失败是硬失败还是软失败

Section titled “7.1 远程配置失败是硬失败还是软失败”

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

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

loadGlobalcachedInvalidateWithTTL(..., Duration.infinity) 包装,更新全局配置后显式 invalidate,见 packages/opencode/src/config/config.ts786-808

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

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

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

对每个配置目录,OpenCode 会确保 .gitignore,后台安装 @opencode-ai/plugin,并收集 fiber;插件真正加载前可以等待这些依赖,见 packages/opencode/src/config/config.ts767-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。

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

Section titled “方法一:把优先级写成可执行顺序”

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

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

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

Section titled “方法二:在来源消失前解析位置敏感值”

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

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

toolsmaxStepsmode 在配置层收敛,消费端只读一种内部模型。

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

先不用源码,用 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

Section titled “最后复盘:配置决定有哪些 OpenCode”

这一章的最小结论是:

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

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