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,閘道啟動序列第一件事。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 三條路徑的關係:
邊界與失敗
- 路徑漂移:閘道啟動時必須先調
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),閘道進入降級模式而非拒絕啟動。但 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。