Skip to content

Zod Schema 运行时校验

源码版本v2026.6.11

职责

Zod schema 是 OpenClaw 配置 (config) 的运行时契约 (contract):openclaw.json 一读进来就被 OpenClawSchema.safeParse 过一遍,所有字段形状、枚举值、provider 必填项、跨字段约束都在这里守门。schema 不通过,throwInvalidConfig 直接抛 INVALID_CONFIG,gateway 拒绝启动——不会偷偷回退到宽松默认值。

schema 模块还负责一部分默认值注入和形态归一化:.default().transform().overwrite().superRefine() 四种钩子在 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 不回退——保证坏配置不会让 gateway 跑起来。
  • .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