Skip to content

Zod-Schema-Laufzeitvalidierung

源码版本v2026.6.11

Verantwortung

Das Zod-Schema ist der Laufzeit-Vertrag (contract) der OpenClaw-Konfiguration (config): Sobald openclaw.json gelesen wurde, läuft es durch OpenClawSchema.safeParse; alle Feldformen, Enum-Werte, Provider-Pflichtfelder und Kreuzfeld-Constraints bewachen hier. Schlägt das Schema fehl, wirft throwInvalidConfig direkt INVALID_CONFIG, und das Gateway verweigert den Start — kein heimlicher Fallback auf laxe Defaults.

Das Schema-Modul übernimmt auch einen Teil der Default-Injektion und Formnormalisierung: .default(), .transform(), .overwrite(), .superRefine() — vier Hook-Typen, die in einem einzigen safeParse-Aufruf alle durchlaufen und ein direkt verwendbares OpenClawConfig-Objekt produzieren.

Designmotivation

Warum Zod statt JSON Schema oder handgeschriebenem if-else? Drei Gründe:

  1. TypeScript homogen: Die Schema-Definition leitet den OpenClawConfig-Typ direkt ab — Schema ändern heißt Typ ändern; Inkonsistenzen fallen schon zur Kompilierzeit.
  2. Komponierbar: provider, channel, approvals, cron, skills haben je ein eigenes Schema und werden in OpenClawSchema auf顶层 zusammengebaut; Plugins können über validateConfigObjectWithPlugins eigene Schema-Fragmente injizieren — das kann JSON Schema nicht dynamisch.
  3. Lesbare Fehlermeldungen: safeParse liefert issues mit Pfad; mapZodIssueToConfigIssue übersetzt Zods interne issues in eine flache path + message-Liste, sodass die CLI dem Nutzer auf tiefen Pfaden wie models.providers.openai.models[0].id genau zeigen kann.

Der Preis: Das Schema-File selbst ist riesig (zod-schema.core.ts über tausend Zeilen), und die Zod-Laufzeitkosten sind bei großer Konfiguration nicht vernachlässigbar — aber gegen die Kosten „Konfigurationsfehler bringt Laufzeit zum Absturz" ist das akzeptabel.

Schlüsseldateien

Datenfluss

Der Validierungs-Eintritt ist validateConfigObjectRaw (validateConfigObjectRaw:1041). Der Kern ist ein einzelnes 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);

Drei Details:

  • stripDeprecatedValidationKeys entfernt vor der Validierung veraltete Felder, sodass das Schema mit .strict() unbekannte Felder ablehnen kann, ohne alte Konfigurationen zu brechen.
  • policyIssues sind Strategieprüfungen außerhalb des Schemas (z. B. ob eine Secret-Ref mutierbar ist) und werden mit Zod-issues zusammen zurückgegeben.
  • materializeBundledModelProviderOverlays materialisiert nach erfolgreicher Validierung die internen Provider-Overlays; so erbt eine vom Nutzer nur als openai-id geschriebene Konfiguration die bundled Default-Model-Liste.

ModelsConfigSchema selbst (ModelsConfigSchema:551) ist klein:

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

.strict() bedeutet: Jedes unbekannte Feld im models-Block (z. B. apiKey versehentlich unter models statt unter providers.openai) wird abgelehnt. mode: "merge" ist das Standardverhalten — bundled-Provider und vom Nutzer deklarierte derselbe-ID-Provider werden verschmolzen; mode: "replace" ersetzt die bundled-Definition vollständig, für eigene OpenAI-kompatible Endpunkte.

ModelProvidersSchema nutzt .superRefine() für Kreuzfeld-Validierung (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 prüft, ob es sich um eine bundled-ID wie openai / anthropic / google handelt; dann darf sie nur Teilfelder überschreiben. Custom-IDs (z. B. my-local-llm) müssen explizit baseUrl und models[] angeben, sonst werden sie abgelehnt. Das verhindert die peinliche Situation „Provider konfiguriert, aber ohne baseUrl, erst zur Laufzeit entdeckt".

Die gesamte Validierungskette:

Grenzen und Fehler

  • Fail closed: Schlägt validateConfigObjectRaw fehl, läuft loadConfigLocal direkt in throwInvalidConfig (src/config/io.ts:1752-1757); der Fehler trägt INVALID_CONFIG-Code, und der catch in loadConfigLocal prüft diesen Code explizit ohne Fallback — eine schlechte Konfiguration bringt das Gateway nicht hoch.
  • .strict() für Rechtschreibung: Das 顶层-Schema und ModelsConfigSchema nutzen beide .strict(); ein verschriebenes providrs wird direkt abgelehnt, vermeidet „Tippfehler im Key wird stillschweigend ignoriert".
  • Plugin-Schema-Injektion: Drittanbieter-Plugins injizieren über validateConfigObjectWithPlugins eigene Schema-Fragmente; bei Fehlern trägt der issue-Pfad das Plugin-Präfix. skipPluginValidation: true ist der Notfallpfad für Plugin-Schemas mit bekanntem Bug.
  • Default vs. optional: Viele Felder nutzen das Muster .default("allowlist").optional().optional() erlaubt im Autor-Zustand das Weglassen; .default() garantiert, dass die Runtime immer einen Wert hat. Diese Unterscheidung macht „Nutzer hat nichts geschrieben" und „Nutzer hat null geschrieben" semantisch verschieden; doctor kann so entscheiden, ob zurückgefüllt werden muss.
  • .overwrite()-Normalisierung: VisibleRepliesSchema akzeptiert "automatic" | "message_tool" oder boolean; letzteres wird über .overwrite() in die entsprechende Enum übersetzt (VisibleRepliesSchema:563-573). Diese Normalisierung erspart Migration, wenn die Altkonfiguration true geschrieben hat.
  • .transform()-Typerzwingung: DiscordIdSchema akzeptiert string | number; da Discord-Snowflake über Number.MAX_SAFE_INTEGER Präzision verliert, transformiert es im transform-Schritt zu string und prüft auf nicht-negativ (DiscordIdSchema).
  • future-version guard: meta.lastTouchedVersion wird über shouldWarnOnTouchedVersion geprüft; wird eine neue Konfiguration von einer alten Version gelesen, nur warn, nicht ablehnen. Das schemaeigene lastTouchedAt akzeptiert string | number; number wird in ISO-String transformiert, damit ein Agent, der Date.now() in die Datei schreibt, die Parse nicht bricht.
  • Kanal-Schema spät gebunden: channel ist ein dynamisches record; das Schema je Kanal-ID registriert das Kanal-Plugin beim Laden, daher validiert validateConfigObjectRaw nur channel-gemeinsame Felder. Die kanalinternen Felder werden nach Plugin-Laden von collectRawBundledChannelConfigIssues nachgeliefert.
  • preserved legacy root keys: Beim doctor-Reparieren historischer Keys lässt preservedLegacyRootKeys diese Keys die Validierung überspringen, aber in der Datei erhalten; so schlägt das Schema nach Verscharfungen nicht für alle Altkonfigurationen gleichzeitig fehl.

Zusammenfassung

Das Zod-Schema ist der Türsteher der OpenClaw-Konfiguration — definiert statische Formen und trägt Kreuzfeld-Strategien und Normalisierung. Ein einzelner safeParse-Aufruf läuft durch alle Prüfungen; bei Misserfolg fail closed. provider / channel / agent haben je eigene Schema-Dateien, in OpenClawSchema auf顶层 zusammengebaut, und Plugins können dynamisch injizieren. Zusammen mit dem Lesepfad von openclaw.json-Konfigurationseingang garantiert die Kette Lesen-Validieren-Materialisieren, dass schlechte Konfigurationen nicht in die Runtime gelangen; die kanalinternen Felder siehe Kanal-Konfiguration.

Vergleich mit offiziellen Ressourcen: Configuration Schema · README.