Skip to content

Zod Schema 執行時校驗

源码版本v2026.6.11

職責

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?三個理由:

  1. TypeScript 同源:schema 定義直接推導出 OpenClawConfig 型別,改 schema 等於改型別,編譯期就能發現不一致。
  2. 可組合:provider、channel、approvals、cron、skills 各自一個 schema,在 OpenClawSchema 頂層拼裝,外掛還能透過 validateConfigObjectWithPlugins 注入自己的 schema 片段——這是 JSON Schema 做不到的動態組合。
  3. 錯誤訊息可讀:safeParse 回傳的 issues 帶路徑,mapZodIssueToConfigIssue 把 Zod 的內部 issue 翻譯成 path + message 的扁平列表,CLI 展示給使用者時能精確定位到 models.providers.openai.models[0].id 這種深度路徑。

代價是 schema 檔案本身巨大(zod-schema.core.ts 一千多行),且 Zod 的執行時開銷在大設定上不可忽略——但相比「設定錯誤導致執行時崩」的成本,這點開銷值得。

關鍵檔案

資料流

校驗入口在 validateConfigObjectRaw(validateConfigObjectRaw:1041)。核心就一行 safeParse:

typescript
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 物化進來,這樣使用者設定裡只寫 openai id 就能繼承 bundled 預設 model 列表。

ModelsConfigSchema 本身(ModelsConfigSchema:551)很小:

typescript
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):

typescript
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)必須顯式給 baseUrlmodels[],否則會被拒絕。這就避免了「配了個 provider 但沒 baseUrl,執行時才發現」的尷尬。

整個校驗鏈路:

邊界與失敗

  • fail closed:validateConfigObjectRaw 不通過時,loadConfigLocal 直接走 throwInvalidConfig(src/config/io.ts:1752-1757),錯誤帶 INVALID_CONFIG code,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.lastTouchedVersionshouldWarnOnTouchedVersion 檢查,新設定被舊版讀時只 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