Skip to content

Validation runtime Zod Schema

源码版本v2026.6.11

Responsabilités

Zod schema est le contrat runtime de la config d'OpenClaw: dès que openclaw.json est lu, il passe par OpenClawSchema.safeParse; tous les shapes de champs, valeurs d'enum, champs requis du provider, contraintes cross-champs y gardent la porte. Si le schema ne passe pas, throwInvalidConfig jette INVALID_CONFIG, la passerelle refuse de démarrer — pas de repli silencieux vers des defaults laxistes.

Le module schema gère aussi une partie de l'injection de defaults et de la normalisation de forme: les quatre hooks .default(), .transform(), .overwrite(), .superRefine() tournent tous en un seul appel safeParse, produisant un objet OpenClawConfig directement utilisable.

Motivation de conception

Pourquoi Zod plutôt que JSON Schema ou du if-else à la main? Trois raisons:

  1. Même origine que TypeScript: la définition du schema dérive directement le type OpenClawConfig; modifier le schema modifie le type, les incohérences sont détectées à la compilation.
  2. Composable: provider, channel, approvals, cron, skills chacun un schema, assemblés au top-level OpenClawSchema; les plugins peuvent aussi injecter leur propre fragment de schema via validateConfigObjectWithPlugins — ce que JSON Schema ne sait pas faire dynamiquement.
  3. Messages d'erreur lisibles: les issues retournés par safeParse portent un path; mapZodIssueToConfigIssue traduit l'issue interne Zod en une liste plate path + message, le CLI peut ainsi localiser précisément models.providers.openai.models[0].id à cette profondeur.

Le coût est que le fichier schema lui-même est énorme (zod-schema.core.ts mille+ lignes), et que le coût runtime Zod n'est pas négligeable sur de grosses configs — mais comparé au coût d'une config erronée qui fait crasher le runtime, ça vaut la peine.

Fichiers clés

Flux de données

L'entrée de validation est dans validateConfigObjectRaw (validateConfigObjectRaw:1041). Le cœur est une seule ligne 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);

Trois détails à noter:

  • stripDeprecatedValidationKeys supprime les champs deprecated avant validation, ainsi le schema peut utiliser .strict() pour rejeter les champs inconnus sans casser les vieilles configs;
  • policyIssues sont des checks de politique hors schema (ex: secret ref est-elle mutable), fusionnés avec les issues Zod en retour;
  • materializeBundledModelProviderOverlays matérialise après validation les overlays de providers builtin, ainsi l'utilisateur peut n'écrire que l'id openai pour hériter des modèles bundled par défaut.

ModelsConfigSchema lui-même (ModelsConfigSchema:551) est tout petit:

typescript
export const ModelsConfigSchema = z
  .object({
    mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
    providers: ModelProvidersSchema.optional(),
    pricing: ModelPricingConfigSchema,
  })
  .strict()
  .optional();

.strict() signifie que tout champ non défini dans le bloc models (par exemple apiKey écrit à tort sous models plutôt que sous providers.openai) sera rejeté. mode: "merge" est le comportement par défaut — les champs du provider bundled et du provider déclaré avec le même id sont fusionnés; mode: "replace" remplace complètement la définition bundled, pour les endpoints compatibles OpenAI custom.

ModelProvidersSchema utilise .superRefine() pour la validation cross-champs (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 détermine si l'id est bundled (openai / anthropic / google etc.), auquel cas on autorise à ne couvrir que certains champs; un id custom (ex my-local-llm) doit explicitement donner baseUrl et models[], sinon rejeté. Cela évite le scénario gênant « provider configuré sans baseUrl, découvert au runtime ».

Toute la chaîne de validation:

Limites et modes d'échec

  • fail closed: quand validateConfigObjectRaw échoue, loadConfigLocal appelle throwInvalidConfig (src/config/io.ts:1752-1757), l'erreur porte le code INVALID_CONFIG, le catch de loadConfigLocal détecte explicitement ce code sans repli — garantit qu'une config cassée ne lance pas la passerelle.
  • .strict() pour la correction orthographique: le schema top-level et ModelsConfigSchema utilisent .strict(), ce qui rejette directement providrs mal orthographié, pour éviter « clé mal écrite puis silencieusement ignorée ».
  • Injection plugin schema: les plugins tiers injectent leur fragment de schema via validateConfigObjectWithPlugins, en cas d'échec le path de l'issue porte le préfixe du plugin. skipPluginValidation: true est un chemin de secours urgent quand le schema d'un plugin a un bug.
  • default vs optional: beaucoup de champs utilisent le patron .default("allowlist").optional().optional() autorise l'auteur à omettre, .default() garantit que le runtime a toujours une valeur. Cette distinction rend « utilisateur n'a rien écrit » et « utilisateur a écrit null » sémantiquement différents, et doctor peut décider s'il faut backfill.
  • Normalisation .overwrite(): VisibleRepliesSchema accepte "automatic" | "message_tool" ou boolean, ce dernier passé via .overwrite() devient l'enum correspondante (VisibleRepliesSchema:563-573). Cette normalisation évite de devoir migrer une vieille config qui écrivait true.
  • Coercion de type .transform(): DiscordIdSchema accepte string | number, mais comme les snowflake Discord dépassent Number.MAX_SAFE_INTEGER perdent en précision, le transform les force en string et valide non négatif (DiscordIdSchema).
  • future version guard: meta.lastTouchedVersion passe par shouldWarnOnTouchedVersion; une config nouvelle version lue par ancienne version n'émet qu'un warn, pas de rejet. Mais le lastTouchedAt du schema lui-même accepte string | number; le number est transformé en ISO string, pour éviter qu'un script agent écrivant Date.now() dans le fichier ne fasse échouer le parsing.
  • Channel schema à liaison différée: channel est un record dynamique, le schema de chaque id de canal est enregistré au chargement du plugin de canal; donc validateConfigObjectRaw ne valide que les champs communs; la validation interne d'un canal est faite après chargement du plugin via collectRawBundledChannelConfigIssues.
  • preserved legacy root keys: quand doctor répare des clés legacy, preservedLegacyRootKeys fait que ces clés sautent la validation mais restent dans le fichier, pour éviter qu'un resserrement du schema ne fasse échouer toutes les vieilles configs d'un coup.

Résumé

Zod schema est le gardien de la config OpenClaw: il définit à la fois la forme statique et porte les politiques cross-champs et les normalisations. safeParse en un seul appel termine tous les checks; en cas d'échec, fail closed. provider / channel / agent chacun leur fichier de schema indépendant, assemblés au top-level OpenClawSchema, les plugins pouvant injecter dynamiquement. Avec le chemin lecture de entrée de config openclaw.json, toute la chaîne lecture-validation-matérialisation garantit qu'une config cassée n'entre pas au runtime; les champs spécifiques côté canal dans canaux: configuration.

Pour comparer avec la documentation officielle: Configuration Schema · README.