openclaw.json 配置入口
职责
openclaw.json 是 OpenClaw 的唯一作者态配置 (config) 文件:gateway 端口、模型 provider、渠道凭据、技能加载路径、cron、记忆策略、approvals、hooks 全都写在这一个 JSON5 文件里。运行时一切配置都从这里读出来,再经过 include 展开、env 变量替换、Zod schema 校验、默认值注入,变成内存里的 OpenClawConfig 对象。
它不只是「读一次就完事」的文件:CLI 的 config set/patch/unset 会原子写回,运行时快照 (runtime snapshot) 会和磁盘内容比对防止竞态,出问题时还能从 last-known-good 备份回滚。所以这个入口承担读、写、校验、备份、迁移五件事。
设计动机
为什么是单文件而不是分散目录?早期 OpenClaw 把 provider、channel、memory 各自塞进独立 JSON,结果是用户改一个 provider 要改三个文件,渠道插件升级后又得把迁移逻辑撒得到处都是。收敛成单个 openclaw.json 之后,版本戳、备份、审计日志都挂在同一份文件上,openclaw doctor 扫描一个路径就能体检。
但单文件又会膨胀,所以同时支持两层逃生:
$include指令把大段 provider/channel 配置拆到子文件,根文件只保留引用;${VAR}环境变量替换让密钥脱离文件入库。
这两个特性让 openclaw.json 既能进 git 仓库(脱敏后),又能跑在 Docker/Tailscale 这类只给环境变量的部署里。
关键文件
paths.ts CONFIG_FILENAME:25-26—openclaw.json文件名常量,旁边还留着 legacyclawdbot.json兜底。resolveStateDir:58-87— 解析状态目录 (state dir),$OPENCLAW_STATE_DIR覆盖,否则走~/.openclaw,再否则回退 legacy.clawdbot。resolveCanonicalConfigPath:152-161— canonical 路径$OPENCLAW_CONFIG_PATH或$stateDir/openclaw.json。resolveConfigPath:191-226— 实际读取时优先命中已存在的候选文件(含 legacy),找不到再回 canonical。normalizeStateDirEnv:89-95— 启动时把$OPENCLAW_STATE_DIR展开~并写回 env,gateway 启动序列第一件事。resolveIncludeRoots:117-143—$OPENCLAW_INCLUDE_ROOTS白名单,允许$include跨出配置目录。createConfigIO:1378-1434— IO 门面工厂,内部封装读、写、快照、备份、校验全流程。loadConfigLocal:1654-1828— 同步读路径核心:dotenv → 读文件 → JSON5 parse → include 展开 → env 替换 → 校验 → 物化运行时配置。writeConfigFileLocal:2269-2366— 写路径核心:比对快照、生成 merge-patch、校验、原子写盘。writeConfigFile 导出:2826-2860— 公共写入口,把 runtime snapshot 投回 source 形态再写。applyMergePatch:83— JSON merge patch 实现,patch 模式的基础。applyConfigOverrides:93— 运行时注入覆盖(测试、CLI 临时参数)。env-substitution.ts—${VAR}解析,缺变量只 warn 不崩。includes.ts—$include指令解析,带路径越界保护。defaults.ts apply*:109-529—applyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaults等默认值注入。runConfigPatch:2279-2309—openclaw config patchCLI 入口。mergeConfigValue:771-790— patch 时的递归合并,对 providermodels[]数组走特殊去重路径。
数据流
启动时,createConfigIO 先把路径常量 pin 住,再走 loadConfigLocal(loadConfigLocal:1654)。读路径的关键几步:
function loadConfigLocal(options: { skipSuspiciousRecovery?: boolean } = {}): OpenClawConfig {
try {
maybeLoadDotEnvForConfig(deps.env);
const envBeforeRead = snapshotEnv(deps.env);
if (!deps.fs.existsSync(configPath)) {
// ... 加载 shell env fallback 后返回空配置
return {};
}
const raw = deps.fs.readFileSync(configPath, "utf-8");
const parsed = deps.json5.parse(raw);
const readResolution = resolveConfigForRead(
resolveConfigIncludesForRead(parsed, configPath, deps),
deps.env,
deps.lowerPrecedenceEnv,
);
// ... shipped plugin install records 迁移、validate、materialize注意三件事:maybeLoadDotEnvForConfig 只在 env 等于 process.env 时才真正加载 dotenv(测试注入的 env 不动);envBeforeRead 用来在写回时检测「读期间 env 是否被改」;resolveConfigIncludesForRead 会把 $include 目标文件读进来,然后再交给 resolveConfigForRead 做 ${VAR} 替换。顺序很关键:include 先于 env 替换,这样 include 进来的字段里也能用 ${VAR}。
写路径走 writeConfigFile(writeConfigFile:2826)。当存在 runtime snapshot 时,它先把 caller 给的 runtime 形配置 diff 成 patch,再投影回 source 形态:
export async function writeConfigFile(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<ConfigWriteResult> {
options.assertConfigPathForWrite?.();
const io = createConfigIO({ /* ... */ });
assertConfigWriteAllowedInCurrentMode({ configPath: io.configPath });
let nextCfg = cfg;
const runtimeConfigSnapshot = getRuntimeConfigSnapshotState();
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState();
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
if (hadBothSnapshots) {
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
}这一步保证了 caller 写回的是「用户作者态」,而不是被运行时默认值污染过的形态——否则 schema 默认值会被固化进文件,改版本时回不来。
CLI patch 模式(runConfigPatch:2279)接收一个 JSON5 patch 对象,递归合并进现有配置:
function mergeConfigValue(existing: unknown, patch: unknown, path: PathSegment[]): unknown {
if (isProviderModelListPath(path) && Array.isArray(existing) && Array.isArray(patch)) {
return mergeModelArrays(existing, patch);
}
if (isPlainRecord(existing) && isPlainRecord(patch)) {
// 深合并:对象递归,非对象直接覆盖models[] 这种数组走 mergeModelArrays,按 id 字段去重后合并元素,而不是简单替换——provider 列表里追加一个 model 不会把原有 model 全抹掉。
整体读、写、patch 三条路径的关系:
边界与失败
- 路径漂移:gateway 启动时必须先调
pinRuntimePaths(pinRuntimePaths:236-245),否则OPENCLAW_CONFIG_PATH在 module import 之后才设置会读到旧值,出现「读一个路径、写另一个路径」的撕裂。 - legacy 回退:
resolveConfigPath先扫clawdbot.json、.clawdbot/,没有再走新路径。升级老部署时若两个目录都存在,会优先新路径;但用户若误把旧文件留在~/.openclaw/,新进程也会读它——doctor 命令专门扫这种分裂。 $include越界:默认只能引用openclaw.json同目录及子目录文件。要跨目录必须显式设$OPENCLAW_INCLUDE_ROOTS,且每个根会被 tilde 展开 + 去重 + 拒绝非绝对路径(include roots filter:128-141)。${VAR}缺失:不抛错,只 warn「feature using this value will be unavailable」(src/config/io.ts:1950-1956),gateway 进入降级模式而非拒绝启动。但 provider apiKey 缺失会在真正发请求时报错。- Nix 模式只读:
OPENCLAW_NIX_MODE=1时,assertConfigWriteAllowedInCurrentMode会拒绝所有写操作,配置由 Nix 外部托管(resolveIsNixMode:16-18)。 - 原子写:
replaceFileAtomic写临时文件再 rename,目录权限0o700、文件权限0o600,Windows 走 copy fallback。 - 大小骤降拦截:写之前对比
previousBytesvsnextBytes,若降到 50% 以下且无allowConfigSizeDrop旗标,直接拒绝写——防止某次 patch 把 provider 整块删掉。 - last-known-good:每次成功读会更新
lastKnownGood指纹(hash + bytes + mtime + dev/ino);若下次读发现 size 暴跌或 gateway mode 字段被抹,自动从.bak恢复,审计日志记一行config.observe事件。 - future version 警告:文件
meta.lastTouchedVersion比当前二进制新时 warn,提示用户 PATH 可能指向旧版 openclaw——避免「新版写出的配置被旧版读坏」。
小结
openclaw.json 是单一入口,但背后是一整套读-校验-物化-写-审计的生命周期。读路径层层展开 include 和 env,写路径把 runtime 形态反向投影回 source 形态,patch 模式给 CLI 提供结构化增量编辑。schema 怎么定义每个字段的合法形状,看 Zod Schema 运行时校验;provider 的具体字段(baseUrl/api/auth/apiKey/models[])在 能力层:模型 Provider 详述;渠道侧配置看 渠道:配置;常驻进程怎么把读到的 config 用起来看 部署:daemon。
对照官方资料:Configuration 文档 · README。