Skip to content

Validación runtime con Zod schema

源码版本v2026.6.11

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:

  1. 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.
  2. 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ía validateConfigObjectWithPlugins — algo que JSON Schema no puede hacer dinámicamente.
  3. Mensajes de error legibles: los issues devueltos por safeParse llevan path, y mapZodIssueToConfigIssue traduce el issue interno de Zod a una lista plana path + message, permitiendo a la CLI localizar con precisión rutas profundas como models.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

Flujo de datos

La entrada de validación está en validateConfigObjectRaw (validateConfigObjectRaw:1041). El núcleo es una sola línea 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);

Tres detalles a notar:

  • stripDeprecatedValidationKeys elimina los campos deprecated antes de validar, así el schema puede usar .strict() para rechazar campos desconocidos sin romper configs viejas;
  • policyIssues son chequeos de política fuera del schema (como si un secret ref es mutable), se mergean con los issues de Zod al devolver;
  • materializeBundledModelProviderOverlays materializa los overlays de provider builtin tras la validación, así al escribir openai como id en la config del usuario se hereda la lista de models bundled.

ModelsConfigSchema en sí (ModelsConfigSchema:551) es pequeño:

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

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 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 validateConfigObjectRaw no pasa, loadConfigLocal va directamente a throwInvalidConfig (src/config/io.ts:1752-1757), el error lleva code INVALID_CONFIG, y el catch de loadConfigLocal detecta explícitamente este code y no hace fallback — garantiza que una config rota no levante el gateway.
  • .strict() corrige typos: el schema top-level y ModelsConfigSchema usan .strict(), rechazando directamente campos mal escritos como providrs, 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: true es 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: VisibleRepliesSchema acepta "automatic" | "message_tool" o boolean, este último se convierte vía .overwrite() al enum correspondiente (VisibleRepliesSchema:563-573). Esta normalización permite que configs viejas con true no requieran migración.
  • .transform() fuerza tipo: DiscordIdSchema acepta string | number, pero un Discord snowflake por encima de Number.MAX_SAFE_INTEGER pierde precisión, así que la fase transform lo fuerza a string y valida que sea no negativo (DiscordIdSchema).
  • Guardián de future version: meta.lastTouchedVersion se chequea con shouldWarnOnTouchedVersion, cuando una config nueva se lee con versión vieja solo warn no rechaza, pero el propio lastTouchedAt del schema acepta string | number, y number se transforma a string ISO, evitando que un script de agent que escribe Date.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 validateConfigObjectRaw solo valida los campos comunes; los campos internos del canal se validan en una segunda ronda por collectRawBundledChannelConfigIssues tras cargar los plugins.
  • preserved legacy root keys: cuando doctor arregla keys legacy, preservedLegacyRootKeys deja 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.