import { z } from "zod"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { ConfigUiHints } from "../shared/config-ui-hints-types.js"; import { FIELD_HELP } from "./schema.help.js"; import { FIELD_LABELS } from "./schema.labels.js"; import { applyDerivedTags } from "./schema.tags.js"; import { sensitive } from "./zod-schema.sensitive.js"; const log = createSubsystemLogger("config/schema"); export type { ConfigUiHint, ConfigUiHints } from "../shared/config-ui-hints-types.js"; const GROUP_LABELS: Record = { wizard: "Wizard", update: "Update", cli: "CLI", diagnostics: "Diagnostics", logging: "Logging", gateway: "Gateway", nodeHost: "Node Host", agents: "Agents", tools: "Tools", bindings: "Bindings", audio: "Audio", models: "Models", messages: "Messages", commands: "Commands", session: "Session", cron: "Cron", hooks: "Hooks", ui: "UI", browser: "Browser", talk: "Talk", channels: "Messaging Channels", skills: "Skills", plugins: "Plugins", discovery: "Discovery", presence: "Presence", voicewake: "Voice Wake", }; const GROUP_ORDER: Record = { wizard: 20, update: 25, cli: 26, diagnostics: 27, gateway: 30, nodeHost: 35, agents: 40, tools: 50, bindings: 55, audio: 60, models: 70, messages: 80, commands: 85, session: 90, cron: 100, hooks: 110, ui: 120, browser: 130, talk: 140, channels: 150, skills: 200, plugins: 205, discovery: 210, presence: 220, voicewake: 230, logging: 900, }; const FIELD_PLACEHOLDERS: Record = { "gateway.remote.url": "ws://host:18789", "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", "gateway.remote.sshTarget": "user@host", "gateway.controlUi.basePath": "/openclaw", "gateway.controlUi.root": "dist/control-ui", "gateway.controlUi.allowedOrigins": "https://control.example.com", "gateway.push.apns.relay.baseUrl": "https://relay.example.com", "channels.mattermost.baseUrl": "https://chat.example.com", "agents.list[].identity.avatar": "avatars/openclaw.png", }; /** * Non-sensitive field names that happen to match sensitive patterns. * These are explicitly excluded from redaction (plugin config) and * warnings about not being marked sensitive (base config). */ const SENSITIVE_KEY_WHITELIST_SUFFIXES = [ "maxtokens", "maxoutputtokens", "maxinputtokens", "maxcompletiontokens", "contexttokens", "totaltokens", "tokencount", "tokenlimit", "tokenbudget", "passwordFile", ] as const; const NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES = SENSITIVE_KEY_WHITELIST_SUFFIXES.map((suffix) => suffix.toLowerCase(), ); const SENSITIVE_PATTERNS = [ /token$/i, /password/i, /secret/i, /api.?key/i, /serviceaccount(?:ref)?$/i, ]; function isWhitelistedSensitivePath(path: string): boolean { const lowerPath = path.toLowerCase(); return NORMALIZED_SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix)); } function matchesSensitivePattern(path: string): boolean { return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); } export function isSensitiveConfigPath(path: string): boolean { return !isWhitelistedSensitivePath(path) && matchesSensitivePattern(path); } export function buildBaseHints(): ConfigUiHints { const hints: ConfigUiHints = {}; for (const [group, label] of Object.entries(GROUP_LABELS)) { hints[group] = { label, group: label, order: GROUP_ORDER[group], }; } for (const [path, label] of Object.entries(FIELD_LABELS)) { const current = hints[path]; hints[path] = current ? { ...current, label } : { label }; } for (const [path, help] of Object.entries(FIELD_HELP)) { const current = hints[path]; hints[path] = current ? { ...current, help } : { help }; } for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) { const current = hints[path]; hints[path] = current ? { ...current, placeholder } : { placeholder }; } return applyDerivedTags(hints); } export function applySensitiveHints( hints: ConfigUiHints, allowedKeys?: ReadonlySet, ): ConfigUiHints { const next = { ...hints }; for (const key of Object.keys(next)) { if (allowedKeys && !allowedKeys.has(key)) { continue; } if (next[key]?.sensitive !== undefined) { continue; } if (isSensitiveConfigPath(key)) { next[key] = { ...next[key], sensitive: true }; } } 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. interface ZodDummy { unwrap: () => z.ZodType; } function isUnwrappable(object: unknown): object is ZodDummy { return ( !!object && typeof object === "object" && "unwrap" in object && typeof (object as Record).unwrap === "function" && !(object instanceof z.ZodArray) ); } 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); } if (isSensitive) { next[path] = { ...next[path], sensitive: true }; } else if (isSensitiveConfigPath(path) && !next[path]?.sensitive) { log.debug(`possibly sensitive key found: (${path})`); } if (currentSchema instanceof z.ZodObject) { const shape = currentSchema.shape; for (const key in shape) { const nextPath = path ? `${path}.${key}` : key; next = mapSensitivePaths(shape[key], nextPath, next); } const catchallSchema = currentSchema._def.catchall as z.ZodType | undefined; if (catchallSchema && !(catchallSchema instanceof z.ZodNever)) { const nextPath = path ? `${path}.*` : "*"; next = mapSensitivePaths(catchallSchema, nextPath, next); } } else if (currentSchema instanceof z.ZodArray) { const nextPath = path ? `${path}[]` : "[]"; next = mapSensitivePaths(currentSchema.element as z.ZodType, nextPath, next); } else if (currentSchema instanceof z.ZodRecord) { const nextPath = path ? `${path}.*` : "*"; next = mapSensitivePaths(currentSchema._def.valueType as z.ZodType, nextPath, next); } else if ( currentSchema instanceof z.ZodUnion || currentSchema instanceof z.ZodDiscriminatedUnion ) { for (const option of currentSchema.options) { next = mapSensitivePaths(option as z.ZodType, path, next); } } else if (currentSchema instanceof z.ZodIntersection) { next = mapSensitivePaths(currentSchema._def.left as z.ZodType, path, next); next = mapSensitivePaths(currentSchema._def.right as z.ZodType, path, next); } return next; } /** @internal */ export const __test__ = { mapSensitivePaths, };