Validación runtime con Zod schema
Responsabilidad
El schema Zod es el contrato runtime de la configuración (config) de OpenClaw: en cuanto se lee openclaw.json se pasa por OpenClawSchema.safeParse, y todas las formas de campo, valores de enum, campos requeridos del provider y restricciones cross-field se verifican aquí. Si el schema no pasa, throwInvalidConfig lanza INVALID_CONFIG y el gateway rechaza arrancar — no cae silenciosamente a defaults permisivos.
El módulo de schema también se ocupa de parte de la inyección de defaults y la normalización de forma: cuatro hooks — .default(), .transform(), .overwrite() y .superRefine() — se ejecutan todos en una sola llamada de safeParse y producen un objeto OpenClawConfig listo para usar.
Motivación de diseño
¿Por qué Zod en lugar de JSON Schema o if-else a mano? Tres razones:
- Mismo origen que TypeScript: la definición del schema infiere directamente el tipo
OpenClawConfig, cambiar el schema equivale a cambiar el tipo, y las inconsistencias se detectan en compilación. - Componible: provider, channel, approvals, cron, skills cada uno con su schema, se ensamblan en el top-level
OpenClawSchema, y los plugins pueden inyectar su propio fragmento de schema víavalidateConfigObjectWithPlugins— algo que JSON Schema no puede hacer dinámicamente. - Mensajes de error legibles: los issues devueltos por
safeParsellevan path, ymapZodIssueToConfigIssuetraduce el issue interno de Zod a una lista planapath + message, permitiendo a la CLI localizar con precisión rutas profundas comomodels.providers.openai.models[0].id.
El coste es que el archivo de schema es enorme (zod-schema.core.ts pasa de mil líneas) y el overhead runtime de Zod no es despreciable con configs grandes — pero comparado con el coste de «config rota causing runtime crash», merece la pena.
Archivos clave
OpenClawSchema top-level:495-560— schema raíz, agrega models / channels / agents / approvals / session / cron / skills / memory / mcp / hooks / gateway / proxy.ModelsConfigSchema:551-558— bloquemodels, conmode: merge|replaceyproviders.ModelProvidersSchema:518-540— registro de provider,superRefineexige que providers custom tenganbaseUrlymodels[], solo los bundled overlay están exentos.zod-schema.providers-core.ts— campos principales del provider (baseUrl / api / auth / apiKey / headers / models[]), también reutilizado por channels.ChannelsSchema:52— config de canal, record dinámico, cada id de canal mapea a un channel schema que provee el plugin.InternalHooksSchema:97-113— config de hooks internos, mapeo de trigger y acción.zod-schema.session.ts— políticas de sesión, retención de mensajes, políticas de envío.zod-schema.approvals.ts— políticas de aprobación de invocación de herramientas (native exec / file write, etc.).zod-schema.agents.ts— config multi-agent (agent list / audio / bindings / broadcast).cron schema:849-860—enabled/store/maxConcurrentRuns/retry.skills schema:1260-1310— cuatro subcamposallowBundled/load/install/limits.validateConfigObjectRaw:1041-1105— entrada de validación Zod, sisafeParseno pasa se mapea a issues.validateConfigObject:1107-1124— encima de raw, añadematerializeRuntimeConfigy produce una runtime config lista para usar.validateConfigObjectWithPlugins:1149-1183— añade validación de schema de plugin, el plugin inyecta sus propias restricciones de campo víavalidateConfigObjectWithPluginsBase.io.invalid-config.ts—throwInvalidConfig, formatea los issues en un mensaje de error y marca el codeINVALID_CONFIG.defaults.ts:109-529— «soft defaults» fuera del schema:applyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaults, etc., invocados en la fasematerializeRuntimeConfig, no se escriben a disco.
Flujo de datos
La entrada de validación está en validateConfigObjectRaw (validateConfigObjectRaw:1041). El núcleo es una sola línea 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);Tres detalles a notar:
stripDeprecatedValidationKeyselimina los campos deprecated antes de validar, así el schema puede usar.strict()para rechazar campos desconocidos sin romper configs viejas;policyIssuesson chequeos de política fuera del schema (como si un secret ref es mutable), se mergean con los issues de Zod al devolver;materializeBundledModelProviderOverlaysmaterializa los overlays de provider builtin tras la validación, así al escribiropenaicomo id en la config del usuario se hereda la lista de models bundled.
ModelsConfigSchema en sí (ModelsConfigSchema:551) es pequeño:
export const ModelsConfigSchema = z
.object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: ModelProvidersSchema.optional(),
pricing: ModelPricingConfigSchema,
})
.strict()
.optional();.strict() significa que cualquier campo no definido en el bloque models (por ejemplo, escribir apiKey bajo models en lugar de providers.openai) se rechaza. mode: "merge" es el comportamiento por defecto — el provider bundled y el provider declarado por el usuario con el mismo id se merguean por campo; mode: "replace" sustituye completamente la definición bundled, usado para endpoints compatibles con OpenAI custom.
ModelProvidersSchema usa .superRefine() para validación cross-field (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 detecta si es un id bundled como openai / anthropic / google, en cuyo caso permite sobrescribir solo algunos campos; un id custom (como my-local-llm) debe dar baseUrl y models[] explícitamente, si no, se rechaza. Evita la situación embarazosa de «configuré un provider pero sin baseUrl, y solo me entero en runtime».
La cadena completa de validación:
Límites y fallos
- Fail closed: cuando
validateConfigObjectRawno pasa,loadConfigLocalva directamente athrowInvalidConfig(src/config/io.ts:1752-1757), el error lleva codeINVALID_CONFIG, y el catch deloadConfigLocaldetecta explícitamente este code y no hace fallback — garantiza que una config rota no levante el gateway. .strict()corrige typos: el schema top-level yModelsConfigSchemausan.strict(), rechazando directamente campos mal escritos comoprovidrs, evitando «key mal escrita silenciosamente ignorada».- Inyección de schema de plugin: los plugins de terceros inyectan su fragmento de schema vía
validateConfigObjectWithPlugins, y si falla, el path del issue lleva prefijo de plugin.skipPluginValidation: truees una vía de emergencia cuando se sabe que un schema de plugin tiene un bug. - default vs optional: muchos campos usan el patrón
.default("allowlist").optional()—.optional()permite omitir en authoring,.default()garantiza que runtime siempre tenga valor. Esta distinción da significados distintos a «el usuario no lo escribió» y «el usuario escribió null», y doctor lo usa para decidir si hay que rellenar. .overwrite()normaliza:VisibleRepliesSchemaacepta"automatic" | "message_tool"oboolean, este último se convierte vía.overwrite()al enum correspondiente (VisibleRepliesSchema:563-573). Esta normalización permite que configs viejas contrueno requieran migración..transform()fuerza tipo:DiscordIdSchemaaceptastring | number, pero un Discord snowflake por encima deNumber.MAX_SAFE_INTEGERpierde precisión, así que la fase transform lo fuerza a string y valida que sea no negativo (DiscordIdSchema).- Guardián de future version:
meta.lastTouchedVersionse chequea conshouldWarnOnTouchedVersion, cuando una config nueva se lee con versión vieja solo warn no rechaza, pero el propiolastTouchedAtdel schema aceptastring | number, y number se transforma a string ISO, evitando que un script de agent que escribeDate.now()al archivo rompa el parseo. - Channel schema con binding lazy: channel es un record dinámico, el schema de cada id de canal lo registra el plugin de canal al cargar, así que
validateConfigObjectRawsolo valida los campos comunes; los campos internos del canal se validan en una segunda ronda porcollectRawBundledChannelConfigIssuestras cargar los plugins. - preserved legacy root keys: cuando doctor arregla keys legacy,
preservedLegacyRootKeysdeja que esas keys salten la validación pero se conserven en el archivo, evitando que un schema más estricto haga que una config vieja falle toda de golpe.
Resumen
El schema Zod es el guardián de la config de OpenClaw, define la forma estática y a la vez asume políticas cross-field y normalización. Una llamada a safeParse corre todos los chequeos, y si falla, fail closed. provider / channel / agent tienen cada uno su archivo de schema independiente, ensamblados en el top-level OpenClawSchema, y los plugins pueden inyectar dinámicamente. Junto con la ruta de lectura de Entrada openclaw.json, la cadena completa leer-validar-materializar garantiza que configs rotas no entren en runtime; los campos concretos del canal en Configuración de canal.
Referencias oficiales: Configuration Schema · README.