Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: bb2d219e1f577c2fc8e4a11b2819251d14d5337e Co-authored-by: akramcodez <179671552+akramcodez@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
235 lines
6.5 KiB
TypeScript
235 lines
6.5 KiB
TypeScript
import { z } from "zod";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { FIELD_HELP } from "./schema.help.js";
|
|
import { FIELD_LABELS } from "./schema.labels.js";
|
|
import { sensitive } from "./zod-schema.sensitive.js";
|
|
|
|
const log = createSubsystemLogger("config/schema");
|
|
|
|
export type ConfigUiHint = {
|
|
label?: string;
|
|
help?: string;
|
|
group?: string;
|
|
order?: number;
|
|
advanced?: boolean;
|
|
sensitive?: boolean;
|
|
placeholder?: string;
|
|
itemTemplate?: unknown;
|
|
};
|
|
|
|
export type ConfigUiHints = Record<string, ConfigUiHint>;
|
|
|
|
const GROUP_LABELS: Record<string, string> = {
|
|
wizard: "Wizard",
|
|
update: "Update",
|
|
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<string, number> = {
|
|
wizard: 20,
|
|
update: 25,
|
|
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<string, string> = {
|
|
"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",
|
|
"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];
|
|
|
|
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 hints;
|
|
}
|
|
|
|
export function applySensitiveHints(
|
|
hints: ConfigUiHints,
|
|
allowedKeys?: ReadonlySet<string>,
|
|
): 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<string, unknown>).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.warn(`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);
|
|
}
|
|
} 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,
|
|
};
|