From d5def704e2f8a6e98aa4e2475a6b24e416be6a8b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 03:18:21 +0100 Subject: [PATCH] refactor(config): harden schema metadata and hint traversal --- src/config/schema-root-metadata.ts | 5 +++ src/config/schema.hints.test.ts | 9 ++++ src/config/schema.hints.ts | 67 +++++++++++++++++++++++++----- src/config/schema.ts | 25 ++++++++--- src/config/zod-schema.ts | 3 +- 5 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 src/config/schema-root-metadata.ts diff --git a/src/config/schema-root-metadata.ts b/src/config/schema-root-metadata.ts new file mode 100644 index 00000000000..7950a40c39e --- /dev/null +++ b/src/config/schema-root-metadata.ts @@ -0,0 +1,5 @@ +export const ROOT_CONFIG_SCHEMA_KEY = "$schema"; + +export const ROOT_CONFIG_METADATA_KEYS = [ROOT_CONFIG_SCHEMA_KEY] as const; + +export const ROOT_CONFIG_METADATA_KEY_SET = new Set(ROOT_CONFIG_METADATA_KEYS); diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 0df9cf123a1..5b4f5f304a8 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -85,4 +85,13 @@ describe("mapSensitivePaths", () => { expect(hints["gateway.auth.token"]?.sensitive).toBe(true); expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true); }); + + it("wrapped main schema still yields sensitive hints", () => { + const wrapped = z.preprocess((value) => value, OpenClawSchema); + const hints = mapSensitivePaths(wrapped, "", {}); + + expect(hints["agents.defaults.memorySearch.remote.apiKey"]?.sensitive).toBe(true); + expect(hints["channels.discord.accounts.*.token"]?.sensitive).toBe(true); + expect(hints["gateway.auth.token"]?.sensitive).toBe(true); + }); }); diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index a39500ae582..476410ba9e8 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -156,9 +156,8 @@ export function applySensitiveHints( return next; } -// Seems to be the only way tsgo accepts us to check if we have a ZodClass -// with an unwrap() method. And it's overly complex because oxlint and -// tsgo are each forbidding what the other allows. +// Tsgo and oxlint disagree on some Zod internals, so keep wrapper checks +// explicit and narrow. interface ZodDummy { unwrap: () => z.ZodType; } @@ -172,19 +171,67 @@ function isUnwrappable(object: unknown): object is ZodDummy { ); } +interface ZodPipeDummy { + _def: { + in?: z.ZodType; + out?: z.ZodType; + }; +} + +function getPipeTraversalSchema(schema: z.ZodType): z.ZodType | null { + if (!(schema instanceof z.ZodPipe)) { + return null; + } + + const pipeSchema = schema as unknown as ZodPipeDummy; + const input = pipeSchema._def.in; + const output = pipeSchema._def.out; + + if (output && !(output instanceof z.ZodTransform)) { + return output; + } + if (input && !(input instanceof z.ZodTransform)) { + return input; + } + return output ?? input ?? null; +} + +function unwrapSchemaForTraversal(schema: z.ZodType): { + schema: z.ZodType; + isSensitive: boolean; +} { + let currentSchema = schema; + let isSensitive = sensitive.has(currentSchema); + + while (true) { + if (isUnwrappable(currentSchema)) { + currentSchema = currentSchema.unwrap(); + isSensitive ||= sensitive.has(currentSchema); + continue; + } + + const pipeTraversalSchema = getPipeTraversalSchema(currentSchema); + if (pipeTraversalSchema) { + currentSchema = pipeTraversalSchema; + isSensitive ||= sensitive.has(currentSchema); + continue; + } + + break; + } + + return { schema: currentSchema, isSensitive }; +} + export function mapSensitivePaths( schema: z.ZodType, path: string, hints: ConfigUiHints, ): ConfigUiHints { let next = { ...hints }; - let currentSchema = schema; - let isSensitive = sensitive.has(currentSchema); - - while (isUnwrappable(currentSchema)) { - currentSchema = currentSchema.unwrap(); - isSensitive ||= sensitive.has(currentSchema); - } + const unwrapped = unwrapSchemaForTraversal(schema); + let currentSchema = unwrapped.schema; + const isSensitive = unwrapped.isSensitive; if (isSensitive) { next[path] = { ...next[path], sensitive: true }; diff --git a/src/config/schema.ts b/src/config/schema.ts index f3ae6bf2fa0..fc91af6fa09 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,6 +1,7 @@ import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; import { CHANNEL_IDS } from "../channels/registry.js"; import { VERSION } from "../version.js"; +import { ROOT_CONFIG_METADATA_KEYS, ROOT_CONFIG_METADATA_KEY_SET } from "./schema-root-metadata.js"; import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -297,17 +298,29 @@ function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[] let cachedBase: ConfigSchemaResponse | null = null; -function stripChannelSchema(schema: ConfigSchema): ConfigSchema { +function stripRootMetadataForUiSchema(schema: ConfigSchema): ConfigSchema { const next = cloneSchema(schema); const root = asSchemaObject(next); if (!root || !root.properties) { return next; } - // Allow `$schema` in config files for editor tooling, but hide it from the - // Control UI form schema so it does not show up as a configurable section. - delete root.properties.$schema; + + // Allow root metadata keys in config files, but keep the Control UI focused + // on user-editable config sections. + for (const key of ROOT_CONFIG_METADATA_KEYS) { + delete root.properties[key]; + } if (Array.isArray(root.required)) { - root.required = root.required.filter((key) => key !== "$schema"); + root.required = root.required.filter((key) => !ROOT_CONFIG_METADATA_KEY_SET.has(key)); + } + return next; +} + +function stripChannelsForUiSchema(schema: ConfigSchema): ConfigSchema { + const next = cloneSchema(schema); + const root = asSchemaObject(next); + if (!root || !root.properties) { + return next; } const channelsNode = asSchemaObject(root.properties.channels); if (channelsNode) { @@ -329,7 +342,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse { schema.title = "OpenClawConfig"; const hints = mapSensitivePaths(OpenClawSchema, "", buildBaseHints()); const next = { - schema: stripChannelSchema(schema), + schema: stripChannelsForUiSchema(stripRootMetadataForUiSchema(schema)), uiHints: hints, version: VERSION, generatedAt: new Date().toISOString(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 517ec16de24..cde3494d6bc 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ROOT_CONFIG_SCHEMA_KEY } from "./schema-root-metadata.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js"; import { ApprovalsSchema } from "./zod-schema.approvals.js"; @@ -95,7 +96,7 @@ const MemorySchema = z export const OpenClawSchema = z .object({ - $schema: z.string().optional(), + [ROOT_CONFIG_SCHEMA_KEY]: z.string().optional(), meta: z .object({ lastTouchedVersion: z.string().optional(),