Validation runtime Zod Schema
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:
- 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. - 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 viavalidateConfigObjectWithPlugins— ce que JSON Schema ne sait pas faire dynamiquement. - Messages d'erreur lisibles: les issues retournés par
safeParseportent un path;mapZodIssueToConfigIssuetraduit l'issue interne Zod en une liste platepath + message, le CLI peut ainsi localiser précisémentmodels.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
OpenClawSchema top-level:495-560— Schema racine, agrège models / channels / agents / approvals / session / cron / skills / memory / mcp / hooks / gateway / proxy tous les sous-schemas.ModelsConfigSchema:551-558— Blocmodels, contientmode: merge|replaceetproviders.ModelProvidersSchema:518-540— Registre de providers,superRefineforce les providers custom à avoirbaseUrletmodels[], seuls les bundled overlays sont exemptés.zod-schema.providers-core.ts— Champs principaux du provider (baseUrl / api / auth / apiKey / headers / models[]), réutilisés par les channels.ChannelsSchema:52— Config des canaux, record dynamique, chaque id de canal correspondant à un channel schema propre au plugin.InternalHooksSchema:97-113— Config des hooks internes, mapping trigger / action.zod-schema.session.ts— Politique de session, rétention de messages, stratégie d'envoi.zod-schema.approvals.ts— Politique d'approbation des appels d'outils (native exec / file write etc.).zod-schema.agents.ts— Config multi-agents (agent list / audio / bindings / broadcast).cron schema:849-860—enabled/store/maxConcurrentRuns/retry.skills schema:1260-1310— Quatre sous-groupesallowBundled/load/install/limits.validateConfigObjectRaw:1041-1105— Entrée de validation Zod,safeParsesi échoue alors mapping en issues.validateConfigObject:1107-1124— AjoutematerializeRuntimeConfigsur le raw, produit un runtime config utilisable.validateConfigObjectWithPlugins:1149-1183— Superpose la validation plugin schema, plugin injecte ses contraintes de champs viavalidateConfigObjectWithPluginsBase.io.invalid-config.ts—throwInvalidConfig, formate les issues en message d'erreur et marque le codeINVALID_CONFIG.defaults.ts:109-529— « Soft defaults » hors schema:applyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaultsetc., appelés à l'étapematerializeRuntimeConfig, non réécrits sur disque.
Flux de données
L'entrée de validation est dans validateConfigObjectRaw (validateConfigObjectRaw:1041). Le cœur est une seule ligne 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);Trois détails à noter:
stripDeprecatedValidationKeyssupprime les champs deprecated avant validation, ainsi le schema peut utiliser.strict()pour rejeter les champs inconnus sans casser les vieilles configs;policyIssuessont des checks de politique hors schema (ex: secret ref est-elle mutable), fusionnés avec les issues Zod en retour;materializeBundledModelProviderOverlaysmatérialise après validation les overlays de providers builtin, ainsi l'utilisateur peut n'écrire que l'idopenaipour hériter des modèles bundled par défaut.
ModelsConfigSchema lui-même (ModelsConfigSchema:551) est tout petit:
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):
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,loadConfigLocalappellethrowInvalidConfig(src/config/io.ts:1752-1757), l'erreur porte le codeINVALID_CONFIG, le catch deloadConfigLocaldé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 etModelsConfigSchemautilisent.strict(), ce qui rejette directementprovidrsmal 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: trueest 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():VisibleRepliesSchemaaccepte"automatic" | "message_tool"ouboolean, ce dernier passé via.overwrite()devient l'enum correspondante (VisibleRepliesSchema:563-573). Cette normalisation évite de devoir migrer une vieille config qui écrivaittrue. - Coercion de type
.transform():DiscordIdSchemaacceptestring | number, mais comme les snowflake Discord dépassentNumber.MAX_SAFE_INTEGERperdent en précision, le transform les force en string et valide non négatif (DiscordIdSchema). - future version guard:
meta.lastTouchedVersionpasse parshouldWarnOnTouchedVersion; une config nouvelle version lue par ancienne version n'émet qu'un warn, pas de rejet. Mais lelastTouchedAtdu schema lui-même acceptestring | number; le number est transformé en ISO string, pour éviter qu'un script agent écrivantDate.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
validateConfigObjectRawne valide que les champs communs; la validation interne d'un canal est faite après chargement du plugin viacollectRawBundledChannelConfigIssues. - preserved legacy root keys: quand doctor répare des clés legacy,
preservedLegacyRootKeysfait 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.