Zod Schema 執行時校驗
職責
Zod schema 是 OpenClaw 設定 (config) 的執行時契約 (contract):openclaw.json 一讀進來就被 OpenClawSchema.safeParse 過一遍,所有欄位形狀、列舉值、provider 必填項、跨欄位約束都在這裡守門。schema 不通過,throwInvalidConfig 直接拋 INVALID_CONFIG,閘道拒絕啟動——不會偷偷回退到寬鬆預設值。
schema 模組還負責一部分預設值注入和形態歸一化:.default()、.transform()、.overwrite()、.superRefine() 四種鉤子 (hook) 在 safeParse 一次呼叫裡全部跑完,產出可以直接用的 OpenClawConfig 物件。
設計動機
為什麼選 Zod 而不是 JSON Schema 或手寫 if-else?三個理由:
- TypeScript 同源:schema 定義直接推導出
OpenClawConfig型別,改 schema 等於改型別,編譯期就能發現不一致。 - 可組合:provider、channel、approvals、cron、skills 各自一個 schema,在
OpenClawSchema頂層拼裝,外掛還能透過validateConfigObjectWithPlugins注入自己的 schema 片段——這是 JSON Schema 做不到的動態組合。 - 錯誤訊息可讀:
safeParse回傳的 issues 帶路徑,mapZodIssueToConfigIssue把 Zod 的內部 issue 翻譯成path + message的扁平列表,CLI 展示給使用者時能精確定位到models.providers.openai.models[0].id這種深度路徑。
代價是 schema 檔案本身巨大(zod-schema.core.ts 一千多行),且 Zod 的執行時開銷在大設定上不可忽略——但相比「設定錯誤導致執行時崩」的成本,這點開銷值得。
關鍵檔案
OpenClawSchema 頂層:495-560— 根 schema,聚合 models / channels / agents / approvals / session / cron / skills / memory / mcp / hooks / gateway / proxy 所有子 schema。ModelsConfigSchema:551-558—models塊,內含mode: merge|replace和providers。ModelProvidersSchema:518-540— provider 註冊表,superRefine強制自訂 provider 必須有baseUrl和models[],bundled overlay 才能豁免。zod-schema.providers-core.ts— provider 主體欄位(baseUrl / api / auth / apiKey / headers / models[]),被 channels 也複用。ChannelsSchema:52— 通道設定,動態 record,每個通道 id 對應一個 channel schema,channel 自己的 schema 由 plugin 提供。InternalHooksSchema:97-113— 內部 hooks 設定,觸發器與動作映射。zod-schema.session.ts— 工作階段策略、訊息保留、發送策略。zod-schema.approvals.ts— 工具呼叫審批策略(native exec / file write 等)。zod-schema.agents.ts— 多 agent 設定(agent list / audio / bindings / broadcast)。cron schema:849-860—enabled/store/maxConcurrentRuns/retry。skills schema:1260-1310—allowBundled/load/install/limits四組子欄位。validateConfigObjectRaw:1041-1105— Zod 校驗入口,safeParse不通過則映射成 issues。validateConfigObject:1107-1124— 在 raw 之上疊加materializeRuntimeConfig,產出可直接用的 runtime config。validateConfigObjectWithPlugins:1149-1183— 疊加 plugin schema 校驗,plugin 透過validateConfigObjectWithPluginsBase注入自己的欄位約束。io.invalid-config.ts—throwInvalidConfig,把 issues 格式化成錯誤訊息並標記INVALID_CONFIGcode。defaults.ts:109-529— schema 外的「軟預設值」:applyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaults等,在materializeRuntimeConfig階段呼叫,不寫回磁碟。
資料流
校驗入口在 validateConfigObjectRaw(validateConfigObjectRaw:1041)。核心就一行 safeParse:
export function validateConfigObjectRaw(
raw: unknown,
opts?: {
sourceRaw?: unknown;
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>;
validateBundledChannels?: boolean;
preservedLegacyRootKeys?: readonly string[];
},
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
const normalizedRaw = stripPreservedLegacyRootKeysForValidation(
stripDeprecatedValidationKeys(raw),
opts?.preservedLegacyRootKeys,
);
const policyIssues = collectUnsupportedSecretRefPolicyIssues(normalizedRaw);
const validated = OpenClawSchema.safeParse(normalizedRaw);
if (!validated.success) {
const schemaIssues = validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue));
return {
ok: false,
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
};
}
const validatedConfig = materializeBundledModelProviderOverlays(validated.data as OpenClawConfig);三個細節值得注意:
stripDeprecatedValidationKeys在校驗前把廢棄欄位抹掉,這樣 schema 可以用.strict()拒絕未知欄位而不破壞老設定;policyIssues是 schema 之外的策略檢查(如 secret ref 是否可變),和 Zod issues 合併回傳;materializeBundledModelProviderOverlays在校驗通過後把內建 provider overlay 物化進來,這樣使用者設定裡只寫openaiid 就能繼承 bundled 預設 model 列表。
ModelsConfigSchema 本身(ModelsConfigSchema:551)很小:
export const ModelsConfigSchema = z
.object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: ModelProvidersSchema.optional(),
pricing: ModelPricingConfigSchema,
})
.strict()
.optional();.strict() 意味著 models 塊裡出現任何未定義欄位(比如把 apiKey 誤寫在 models 下而不是 providers.openai 下)都會被拒絕。mode: "merge" 是預設行為——bundled provider 和使用者宣告的同 id provider 欄位合併;mode: "replace" 則完全替換 bundled 定義,用於自訂 OpenAI 相容端點。
ModelProvidersSchema 用 .superRefine() 做跨欄位校驗(ModelProvidersSchema:518-540):
const ModelProvidersSchema = z
.record(z.string(), ModelProviderSchema)
.superRefine((providers, ctx) => {
for (const [providerId, provider] of Object.entries(providers)) {
if (isBuiltInModelProviderOverlayId(providerId)) {
continue;
}
if (!provider.baseUrl) {
ctx.addIssue({
code: "custom",
path: [providerId, "baseUrl"],
message:
"custom model providers must declare baseUrl; provider overlays without baseUrl are only supported for bundled providers",
});
}isBuiltInModelProviderOverlayId 判斷是不是 openai / anthropic / google 等 bundled id,是的話允許只覆蓋部分欄位;自訂 id(如 my-local-llm)必須顯式給 baseUrl 和 models[],否則會被拒絕。這就避免了「配了個 provider 但沒 baseUrl,執行時才發現」的尷尬。
整個校驗鏈路:
邊界與失敗
- fail closed:
validateConfigObjectRaw不通過時,loadConfigLocal直接走throwInvalidConfig(src/config/io.ts:1752-1757),錯誤帶INVALID_CONFIGcode,loadConfigLocal的 catch 顯式判斷這個 code 不回退——保證壞設定不會讓閘道跑起來。 .strict()拼寫糾錯:頂層 schema 和ModelsConfigSchema都用.strict(),把providrs這種拼錯的欄位直接拒掉,避免「寫錯 key 後默默忽略」。- plugin schema 注入:第三方外掛透過
validateConfigObjectWithPlugins注入自己的 schema 片段,失敗時 issue 路徑會帶上 plugin 前綴。skipPluginValidation: true用於已知外掛 schema 有 bug 的緊急修復路徑。 - default vs optional:很多欄位用
.default("allowlist").optional()模式——.optional()允許作者態省略,.default()保證 runtime 總能拿到值。這層區分讓「使用者沒寫」和「使用者寫了 null」語義不同,doctor 也能據此判斷是否需要回填。 .overwrite()歸一化:VisibleRepliesSchema接受"automatic" | "message_tool"或boolean,後者透過.overwrite()轉成對應列舉(VisibleRepliesSchema:563-573)。這種歸一化讓老設定寫true不必遷移。.transform()型別強制:DiscordIdSchema接受string | number,但 Discord snowflake 超過Number.MAX_SAFE_INTEGER時會丟精度,所以 transform 階段強制轉成 string 並校驗非負(DiscordIdSchema)。- future version guard:
meta.lastTouchedVersion走shouldWarnOnTouchedVersion檢查,新設定被舊版讀時只 warn 不拒,但 schema 本身的lastTouchedAt接受string | number,number 會被 transform 成 ISO 字串,避免 agent 腳本寫Date.now()進檔案後解析失敗。 - channel schema 延遲綁定:channel 是動態 record,每個 channel id 對應的 schema 由 channel plugin 在載入時註冊,所以
validateConfigObjectRaw只校驗 channel 共通欄位;channel 內部欄位校驗在 plugin 載入後由collectRawBundledChannelConfigIssues補一輪。 - preserved legacy root keys:doctor 修復歷史遺留 key 時,
preservedLegacyRootKeys讓這些 key 跳過校驗但保留在檔案裡,避免 schema 收緊後老設定一次性全報錯。
小結
Zod schema 是 OpenClaw 設定的守門人,既定義靜態形狀,也承擔跨欄位策略和歸一化。safeParse 一次呼叫跑完所有檢查,失敗就 fail closed。provider / channel / agent 各有獨立 schema 檔案,在 OpenClawSchema 頂層拼裝,plugin 還能動態注入。配合 openclaw.json 設定入口 的讀路徑,整套讀-校驗-物化鏈路保證壞設定進不了 runtime;channel 側具體欄位看 通道:設定。
對照官方資料:Configuration Schema · README。