Zod-Schema-Laufzeitvalidierung
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:
- TypeScript homogen: Die Schema-Definition leitet den
OpenClawConfig-Typ direkt ab — Schema ändern heißt Typ ändern; Inkonsistenzen fallen schon zur Kompilierzeit. - Komponierbar: provider, channel, approvals, cron, skills haben je ein eigenes Schema und werden in
OpenClawSchemaauf顶层 zusammengebaut; Plugins können übervalidateConfigObjectWithPluginseigene Schema-Fragmente injizieren — das kann JSON Schema nicht dynamisch. - Lesbare Fehlermeldungen:
safeParseliefert issues mit Pfad;mapZodIssueToConfigIssueübersetzt Zods interne issues in eine flachepath + message-Liste, sodass die CLI dem Nutzer auf tiefen Pfaden wiemodels.providers.openai.models[0].idgenau 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
OpenClawSchema 顶层:495-560— Wurzel-Schema, aggregiert models / channels / agents / approvals / session / cron / skills / memory / mcp / hooks / gateway / proxy alle Sub-Schemas.ModelsConfigSchema:551-558—models-Block mitmode: merge|replaceundproviders.ModelProvidersSchema:518-540— Provider-Registry;superRefineerzwingt für Custom-ProviderbaseUrlundmodels[]; nur bundled overlay ist ausgenommen.zod-schema.providers-core.ts— Provider-Hauptfelder (baseUrl / api / auth / apiKey / headers / models[]), auch von channels genutzt.ChannelsSchema:52— Kanal-Konfiguration, dynamisches record, jede Kanal-ID mappen auf ein Kanal-Schema, das vom Plugin gestellt wird.InternalHooksSchema:97-113— Interne Hooks-Konfiguration, Trigger-Aktion-Mapping.zod-schema.session.ts— Sitzungsstrategie, Nachrichtenretention, Sendestrategie.zod-schema.approvals.ts— Werkzeugaufruf-Approval-Strategie (native exec / file write u. a.).zod-schema.agents.ts— Multi-Agent-Konfiguration (agent list / audio / bindings / broadcast).cron schema:849-860—enabled/store/maxConcurrentRuns/retry.skills schema:1260-1310—allowBundled/load/install/limitsvier Sub-Felder.validateConfigObjectRaw:1041-1105— Zod-Validierungs-Eintritt; bei nicht erfolgreichemsafeParsewerden issues gemappt.validateConfigObject:1107-1124— Legt über rawmaterializeRuntimeConfigund liefert eine direkt verwendbare Runtime-Config.validateConfigObjectWithPlugins:1149-1183— Legt Plugin-Schema-Validierung obendrauf; Plugin injiziert übervalidateConfigObjectWithPluginsBaseeigene Feld-Constraints.io.invalid-config.ts—throwInvalidConfig; formatiert issues zu einer Fehlermeldung und markiertINVALID_CONFIG-Code.defaults.ts:109-529— „Weiche Defaults" außerhalb des Schemas:applyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaultsu. a., aufgerufen in dermaterializeRuntimeConfig-Phase; nicht auf Platte geschrieben.
Datenfluss
Der Validierungs-Eintritt ist validateConfigObjectRaw (validateConfigObjectRaw:1041). Der Kern ist ein einzelnes 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);Drei Details:
stripDeprecatedValidationKeysentfernt vor der Validierung veraltete Felder, sodass das Schema mit.strict()unbekannte Felder ablehnen kann, ohne alte Konfigurationen zu brechen.policyIssuessind Strategieprüfungen außerhalb des Schemas (z. B. ob eine Secret-Ref mutierbar ist) und werden mit Zod-issues zusammen zurückgegeben.materializeBundledModelProviderOverlaysmaterialisiert nach erfolgreicher Validierung die internen Provider-Overlays; so erbt eine vom Nutzer nur alsopenai-id geschriebene Konfiguration die bundled Default-Model-Liste.
ModelsConfigSchema selbst (ModelsConfigSchema:551) ist klein:
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):
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
validateConfigObjectRawfehl, läuftloadConfigLocaldirekt inthrowInvalidConfig(src/config/io.ts:1752-1757); der Fehler trägtINVALID_CONFIG-Code, und der catch inloadConfigLocalprüft diesen Code explizit ohne Fallback — eine schlechte Konfiguration bringt das Gateway nicht hoch. .strict()für Rechtschreibung: Das 顶层-Schema undModelsConfigSchemanutzen beide.strict(); ein verschriebenesprovidrswird direkt abgelehnt, vermeidet „Tippfehler im Key wird stillschweigend ignoriert".- Plugin-Schema-Injektion: Drittanbieter-Plugins injizieren über
validateConfigObjectWithPluginseigene Schema-Fragmente; bei Fehlern trägt der issue-Pfad das Plugin-Präfix.skipPluginValidation: trueist 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:VisibleRepliesSchemaakzeptiert"automatic" | "message_tool"oderboolean; letzteres wird über.overwrite()in die entsprechende Enum übersetzt (VisibleRepliesSchema:563-573). Diese Normalisierung erspart Migration, wenn die Altkonfigurationtruegeschrieben hat..transform()-Typerzwingung:DiscordIdSchemaakzeptiertstring | number; da Discord-Snowflake überNumber.MAX_SAFE_INTEGERPräzision verliert, transformiert es im transform-Schritt zu string und prüft auf nicht-negativ (DiscordIdSchema).- future-version guard:
meta.lastTouchedVersionwird übershouldWarnOnTouchedVersiongeprüft; wird eine neue Konfiguration von einer alten Version gelesen, nur warn, nicht ablehnen. Das schemaeigenelastTouchedAtakzeptiertstring | number; number wird in ISO-String transformiert, damit ein Agent, derDate.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
validateConfigObjectRawnur channel-gemeinsame Felder. Die kanalinternen Felder werden nach Plugin-Laden voncollectRawBundledChannelConfigIssuesnachgeliefert. - preserved legacy root keys: Beim doctor-Reparieren historischer Keys lässt
preservedLegacyRootKeysdiese 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.