refactor(config): harden schema metadata and hint traversal

This commit is contained in:
Peter Steinberger
2026-02-14 03:18:21 +01:00
parent 55e4a5c227
commit d5def704e2
5 changed files with 92 additions and 17 deletions

View File

@@ -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<string>(ROOT_CONFIG_METADATA_KEYS);

View File

@@ -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);
});
});

View File

@@ -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 };

View File

@@ -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(),

View File

@@ -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(),