开源终端 Agent opencode 的配置从哪读,改错一处为何全变

2026-08-04

本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。

opencode 的配置不是”读一个文件”,是把八个来源按固定顺序深度合并成一份内存对象;而这条链路上,全局配置读坏了会被静默吞掉退回空对象,项目配置读坏了会直接让进程死掉——同一个拼写错误放在两个位置,你看到的现象完全不同。 明白这一点,你排查”我明明改了配置却没生效”的时间能从半小时压到一分钟。

opencode 是一个用 MIT 许可证开源的终端编码 Agent(仓库根目录 LICENSE,Copyright 2025 opencode),packages/ 下有 32 个包。它会在你的机器上跑 shell 命令、直接改你的源码文件、把文件内容发给模型服务商——所以”配置从哪读”不是洁癖问题,是安全边界问题:权限规则写在哪一层、被谁覆盖,决定了它能不能不打招呼就删你的文件。

站内已经写过几篇相邻的东西,分工说清楚:pi 的配置装载顺序 讲的是另一套终端 Agent 的运行时配置,项目宪法文件怎么组织 讲的是仓库里那些规则文件的分层,CLAUDE.md 怎么写 讲的是写给模型看的内容本身;本篇只管 opencode 从磁盘到内存这一段——谁先读、谁覆盖谁、坏了怎么表现。

一、这套配置由哪几块拼起来

先把地图铺开。下面每一行的仓库位置都是实际存在的文件,你可以自己打开对照。

组成部分它负责什么对应仓库位置你什么时候会碰到它
装配主流程拉取远程 / 全局 / 自定义 / 项目 / 托管配置,按顺序深度合并成一份对象packages/opencode/src/config/config.ts每次会话启动
变量替换对配置文本{env:VAR}{file:path} 两种替换packages/opencode/src/config/variable.ts配置里放密钥或长指令
解析与校验JSONC 解析报错定位、顶层未知键拦截、schema 解码packages/opencode/src/config/parse.ts键名写错、少写逗号
路径发现向上逐级查找 opencode.json(c).opencode 目录packages/opencode/src/config/paths.tsmonorepo 子目录里启动
托管配置系统级目录与 macOS 托管偏好读取packages/opencode/src/config/managed.ts公司统一下发策略
目录型资源agent/command/plugin/ 等目录加载条目packages/opencode/src/config/agent.tscommand.tsplugin.ts写自定义 agent 或命令
错误翻译把内部错误变成人能读的一段话packages/opencode/src/cli/error.ts启动失败时看到的提示
配置项文档每个键的含义与示例packages/web/src/content/docs/config.mdx查某个键该怎么写

值得先记住的是分工:variable.ts 处理的是字符串parse.ts 才把字符串变成对象,config.ts 负责把多个对象叠起来。三步严格串行,顺序不能颠倒——后面几乎所有反直觉的现象都能追到这个顺序上。

二、两层文件不止两层:发现顺序与合并顺序

文档 config.mdx 给出的优先级清单是八级,从低到高依次是:远程配置(.well-known/opencode)、全局配置、OPENCODE_CONFIG 指向的自定义文件、项目根的 opencode.json.opencode 目录、OPENCODE_CONFIG_CONTENT 环境变量里的内联配置、系统托管配置文件、macOS 托管偏好。后面的覆盖前面的,托管层覆盖一切。

代码里比文档多一步。config.ts 的装配函数在读完 OPENCODE_CONFIG_CONTENT 之后,如果当前登录账号带有活跃组织,还会去 ${url}/api/config 拉一份配置并以 global 作用域并入,然后才轮到托管目录和 macOS 托管偏好。这一步在文档里没有单独列出来,你排查”配置里冒出个我没写过的 provider”时值得想起它——代码里正是从这份配置的 provider 键里收集出一份”由控制台托管的 provider”名单。

所谓”全局与项目两层”,两层内部各自还有层次。

全局侧的候选文件有三个。写入时按 opencode.jsoncopencode.jsonconfig.json 的顺序取第一个已存在的;读取时三个全读,顺序是 config.jsonopencode.jsonopencode.jsonc,后读的覆盖先读的。也就是说,如果你的全局目录里同时躺着 opencode.jsonopencode.jsonc,起作用的是 .jsonc 那份,而你很可能只在改 .json。另外,当这三个文件一个都不存在、并且你没有设 OPENCODE_CONFIGOPENCODE_CONFIG_DIROPENCODE_CONFIG_CONTENT 中任何一个时,启动会自动创建一份只含 $schema 的全局配置文件。

项目侧不是”只看项目根”。paths.ts 里的查找函数从当前目录开始,以 opencode.jsoncopencode.json 为目标一路向上走到 worktree 为止,把结果反转后返回:

return (yield* afs.up({
  targets: [`${name}.jsonc`, `${name}.json`],
  start: directory,
  stop: worktree,
})).toReversed()

反转的意义是合并顺序变成”从远到近”——离你当前工作目录越近的那份配置越晚合并、优先级越高。在 monorepo 里这条很要命:你在 packages/foo/ 下改了一份 opencode.json,仓库根还有一份,最终生效的是两份深度合并的结果,而不是任何一份的原样。

.opencode 目录的收集是另一条线。它汇总的是全局配置目录、从当前目录向上找到的各级 .opencode、用户主目录下的 .opencode,以及 OPENCODE_CONFIG_DIR 指向的目录,去重后逐个处理,但两件事的适用范围不一样:读取目录里的 opencode.json / opencode.jsonc 只对以 .opencode 结尾的目录、以及 OPENCODE_CONFIG_DIR 指向的那个目录做(全局配置目录在上一步已经单独读过,这里不重复读);而扫描 agent/command/plugin/ 这些子目录,是对列表里每个目录都做。所以你把自定义 agent 放进全局配置目录的 agent/ 下是能被加载的,那条路径走的是子目录扫描而不是配置文件读取。文档补充说这些子目录同时接受复数名(agents/commands/modes/plugins/skills/tools/themes/),单数名为向后兼容保留——代码里的扫描模式确实写成 {agent,agents}/**/*.md 这种两选一的形式。

合并本身用的是深度合并,但有两个例外值得单独记:instructions 数组是并集去重而不是覆盖;plugin 列表则通过一份”来源台账”累积,每条插件规格都带着它来自哪个文件、属于 local 还是 global 作用域,去重后再写回。除此之外的数组字段,按覆盖理解就行。

三、变量替换发生在解析之前

variable.ts 只做两件事,但两件事的行为不对称,这是最容易踩的一处。

环境变量替换是一次无条件的正则替换:

let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
  return (input.env?.[varName] ?? process.env[varName]) || ""
})

三个结论从这三行里直接读出来:第一,环境变量不存在时替换成空字符串,不报错——你的 apiKey 会变成空串,然后你在完全不同的地方看到一个认证失败;第二,替换结果不做任何转义,值里如果有引号、反斜杠或换行,替换完的文本就不再是合法 JSON 了;第三,它不关心这行是不是注释。

文件替换的路子不一样。它先把所有 {file:...} 匹配收集出来逐个处理:如果 token 所在行去掉前导空白后以 // 开头,就原样保留不替换;路径以 ~/ 开头会展开到主目录,相对路径按配置文件所在目录解析;读到的内容 trim() 之后,用 JSON.stringify(...).slice(1, -1) 插进去——也就是自动转义。文件读不到时默认抛错,而不是像环境变量那样变空串,ENOENT 还会在错误消息里附上解析后的绝对路径。

所以经验法则很直白:短的、干净的值用 {env:},多行或含特殊字符的值用 {file:} 把一段带引号的 system prompt 从环境变量塞进配置,出来的报错会是”JSON 解析失败”,你盯着自己写的那行 JSON 怎么看都没错。

替换发生在解析之前,还有一个安全后果。JSONC 解析失败时抛出的错误,消息体里包含替换之后的完整配置文本,而 cli/error.ts 里对这类错误的处理是把这段消息原样接在提示后面输出。翻译过来:你的配置 JSON 少写一个逗号,而里面用 {env:} 注入了 API Key,那么终端上打印出来的那段”JSONC Input”里就是明文密钥。贴 issue、贴群、贴给同事之前,先自己看一眼。

同理,opencode debug config 这条命令的作用是把解析合并后的完整配置打印到标准输出——排查覆盖关系时它是最有用的一条命令,因为它给的是最终结果而不是你以为的结果;但它打印的同样是替换后的值。

四、schema 校验拦什么、不拦什么,以及失败路径的不对称

解析这一步用的是允许尾逗号的 JSONC 解析,报错时会把行号、列号和出问题那一行连同一个 ^ 指针一起打出来,定位体验是好的。

校验分两段。第一段是顶层未知键检查:把 schema 声明的顶层属性名收集成集合,配置对象里凡是不在集合里的顶层键,直接抛”Unrecognized keys”并列出键名。第二段才是完整 schema 解码,且开了 errors: "all",一次把所有问题都报出来而不是遇到第一个就停。

这里有两个边界要说清楚。其一,未知键检查只查顶层,而且当 schema 带索引签名时会直接跳过——嵌套对象里写错的键名不由这一步拦截,能不能被发现取决于对应子 schema 严不严。其二,也是更关键的一点:顶层键名拼错不是”这个键被忽略”,而是整份配置文件不合法。你想给 permission 加个开关结果写成了别的名字,后果不是那个开关不生效,是这个文件里所有设置一起失效。

失败之后会发生什么,取决于这份配置在哪一层。

全局配置的读取包了一层缓存,缓存里的失败处理是这样的:

Effect.tapError((error) =>
  Effect.logError("failed to load global config, using defaults", { error: String(error) }),
),
Effect.orElseSucceed((): Info => ({})),

记一条日志,然后返回空对象。你的全局配置里那些 provider、permission、instructions 全部消失,会话照常启动,界面上没有红字。这就是本篇开头那句判断的来源:同样一个拼写错误,写在全局配置里表现为”配置像是被人清空了”,写在项目配置里表现为”根本起不来”——因为实例级的装配整段是不可恢复失败,会直接终止。

顺带一提,装配尾部还有一串归一化处理,都在 config.ts 里,它们解释了另一类”我没配它为什么它变了”:顶层的 themekeybindstui 这三个旧键在加载时会被剥掉(TUI 设置已经搬到独立的 tui.json);mode 下的条目会被并进 agent 并标成 primary;OPENCODE_PERMISSION 环境变量里的 JSON 会并进权限规则,解析失败只记一条警告并跳过;tools 这个布尔开关映射会被翻译成权限规则,其中 writeeditpatch 三个名字统统折叠成 edit,并且显式写的 permission 优先级高于由 tools 推导出来的那份;没写 username 就取系统用户名,取不到回落成 user;老的 autoshare: true 在没写 share 时等价于 share: "auto"

五、边界与代价:这套设计放弃了什么

没有条件与表达式。 替换只有 {env:}{file:} 两种,没有默认值语法、没有分支、没有拼接函数。想按环境切两套配置,得靠 OPENCODE_CONFIG 指向不同文件,或者靠 OPENCODE_CONFIG_CONTENT 在启动时塞一整段 JSON,而不是在配置里写逻辑。这是取简单换可预测,但它意味着复杂场景的分支逻辑得挪到外面的脚本里。

有些设置放在项目层天然不生效。 仓库里的 specs/v2/config.md 是一份逐组评审配置字段的文档,它给 server 这一组的结论是不再移植,理由写得很清楚:位置相关的配置是在服务已经跑起来之后才加载的。这条理由对当前版本同样成立——把 server.port 写进项目配置,指望它改变已经启动的服务,方向就是错的。

托管层不可被用户覆盖。 系统托管目录(macOS 是 /Library/Application Support/opencode/,Linux 是 /etc/opencode/,Windows 是 %ProgramData%\opencode)和 macOS 的托管偏好域位于优先级最顶端。对企业这是能力,对个人这是代价:如果你的机器由 MDM 管着,你在任何本地文件里写的对应设置都不会赢。

目录型资源的容错程度不一致,而且分成两道关。 第一道是读 markdown 的前置元数据:agent/command/mode/ 三类目录一视同仁,读不动就跳过这个文件,继续下一个,不留任何提示。第二道是字段校验,这里才分岔:mode/ 下校验不过的条目被悄悄丢掉,只有通过的才进最终结果;agent/command/ 下校验不过则直接抛错,把整个装配拖停。所以”我加了个 agent 文件但它没出现”这个现象,背后可能是前置元数据坏了被静默跳过,而”我加了个 mode 文件但它没出现”则可能是字段写错被静默丢弃——两种都不报错,得靠你自己去比对解析输出里有没有这个条目。

它不替你判断命令危不危险。 权限规则是配置层的允许/询问/拒绝,文档明确写着默认状态是允许所有操作、不需要显式批准。也就是说,你什么都不配,就是把”在你的机器上跑任意 shell 命令、任意改写文件”这件事默认打开了。在陌生仓库、在有生产凭据的机器上、在你不打算逐条看它干了什么的时候,这个默认值不合适。

配置文件里的密钥有多个外泄面。 值会被替换进内存并可能被打印(解析报错、debug config);instructions 指向的文件内容会被自动送进模型上下文,等于把这些文件的内容交给了模型服务商。文档说项目配置”可以安全地提交进 Git”,这句话的前提是里面不放明文密钥——用 {file:} 指向仓库外的密钥文件才是配套写法。各家服务商对数据处理的规则不同且会调整,以官方最新说明为准。

当前键名不承诺稳定。 那份 v2 评审文档里,providerpermissionpluginattachmentsnapshot 等键都被标记为改成复数或重新设计,commandsmall_modeltoolsdefault_agent 等被标记为不再移植。这是仓库里记录的评审意向,不是发布承诺,但它至少提示你:把 opencode 的配置键硬编码进团队脚本,要留升级余量。

关于终端 Agent 的权限该收到什么程度,站内另有两篇专门讲:最小权限怎么设计权限放太大会出什么事

六、上手与避坑清单

1. 别用”看文件”确认配置,用 opencode debug config 会踩是因为最终配置是八个来源深度合并的结果,任何一份文件都不等于结果。避法:改完先看解析输出,确认那个键的值真的是你写的那个。

2. 全局配置改完毫无反应,先怀疑它整份没加载。 会踩是因为全局配置的加载失败被降级成一条日志加一个空对象,界面上不报错。避法:在解析输出里找一个只有全局配置才会设置的键,如果它也不见了,就是整份挂了,去看启动日志。

3. 全局目录里别同时留 opencode.jsonopencode.jsonc 会踩是因为读取时两份都读、.jsonc 后读所以赢,而你可能在改另一份。避法:只留一份。

4. 顶层键名拼错的代价是整份文件失效,不是那一项失效。 会踩是因为顶层未知键触发的是校验错误而不是忽略。避法:每份配置第一行都写上 $schema,让编辑器在你保存前就标红。

5. 多行内容和含特殊字符的值一律用 {file:} 会踩是因为 {env:} 的替换结果不转义,一个引号就能把 JSON 弄坏,而报错指向的是 JSON 语法,跟环境变量看着毫无关系。避法:{env:} 只放简短的纯净值。

6. {file:} 指向的文件必须真的存在。 会踩是因为它和 {env:} 的缺失策略相反——环境变量缺失变空串,文件缺失直接抛错。避法:路径写绝对路径或 ~/ 开头,别赌相对路径的基准目录(它是配置文件所在目录,不是你的当前工作目录)。

7. 贴报错前先脱敏。 会踩是因为 JSONC 解析错误的消息里带着替换后的完整配置文本。避法:养成”截图前先扫一眼有没有以 sk- 之类开头的串”的习惯,或者直接把配置改成用 {file:},这样即使打印出来也是文件内容而不是键值本身……前提是那个文件不是密钥本身。更稳的做法是先修好 JSON 再贴。

8. monorepo 里先确认自己改的是哪一层。 会踩是因为查找会一路向上到 worktree,越近的越优先,你以为改的”项目配置”上面可能还压着仓库根那份。避法:在实际启动目录下跑一次解析输出对照。

9. 别在 toolspermission 之间反复横跳。 会踩是因为 tools 会被翻译成权限规则,且 writeeditpatch 折叠成同一个 edit,同时显式写的 permission 会盖过它。避法:统一只用 permission 表达访问控制。

10. 新机器第一件事是把权限从默认全放行改掉。 会踩是因为默认不需要任何批准,装完就能改盘跑命令。避法:至少把 editbash 设成需要询问,在别人的仓库、有生产凭据的机器上尤其如此。

11. 你的配置文件会被工具改。 会踩是因为加载时如果发现文件里没有 $schema,会在内存里补上,并把这一行插进文件的第一个 { 之后写回磁盘。避法:知道有这回事就行,别把这行 diff 当成误操作;写不进去(比如只读文件)时它会静默放弃。

收束

如果只带走一句:opencode 的配置是一条”文本替换 → JSONC 解析 → schema 校验 → 深度合并”的流水线,出问题时先判断卡在哪一步——文本阶段的问题看起来像 JSON 语法错,校验阶段的问题看起来像整份文件失效,合并阶段的问题看起来像”我改的地方被谁盖了”,而全局层的问题看起来像”什么都没发生”。

想继续往下读,路线建议是:先读 packages/opencode/src/config/config.ts 里的装配函数,把八个来源的合并顺序在脑子里过一遍;再读 packages/opencode/src/config/variable.ts(只有九十来行,收益最高);然后读 packages/opencode/src/config/parse.ts 弄清校验拦什么;最后翻 packages/web/src/content/docs/config.mdx 查具体键。四个文件读完,你对”改哪儿、为什么没生效”的判断基本就稳了。

本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 开源终端 Agent opencode 的两条模型接入路线怎么选终端编码 Agent opencode 命令行:交互模式与脚本化执行

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。