mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-06 05:11:36 +00:00
* refactor: remove 3,543 lines of redundant runtime and tests * refactor: ratchet production environment variable budget
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
import { asNullableObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
|
|
import { unsupportedSecretRefSurfacePolicy } from "../secrets/unsupported-surface-policy.js";
|
|
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
|
|
import type { ConfigValidationIssue } from "./types.js";
|
|
import { coerceSecretRef } from "./types.secrets.js";
|
|
import { bundledChannelSchemaById } from "./validation-channel-rules.js";
|
|
|
|
type UnknownIssueRecord = Record<string, unknown>;
|
|
type ConfigPathSegment = string | number;
|
|
type AllowedValuesCollection = {
|
|
values: unknown[];
|
|
incomplete: boolean;
|
|
hasValues: boolean;
|
|
};
|
|
type JsonSchemaLike = Record<string, unknown>;
|
|
|
|
const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
|
|
const SECRETREF_POLICY_DOC_URL = "https://docs.openclaw.ai/reference/secretref-credential-surface";
|
|
|
|
function toConfigPathSegments(path: unknown): ConfigPathSegment[] {
|
|
if (!Array.isArray(path)) {
|
|
return [];
|
|
}
|
|
return path.filter((segment): segment is ConfigPathSegment => {
|
|
const segmentType = typeof segment;
|
|
return segmentType === "string" || segmentType === "number";
|
|
});
|
|
}
|
|
|
|
function formatConfigPath(segments: readonly ConfigPathSegment[]): string {
|
|
return segments.join(".");
|
|
}
|
|
|
|
export function withConfigIssuePath(
|
|
issue: ConfigValidationIssue,
|
|
pathSegments: readonly ConfigPathSegment[],
|
|
): ConfigValidationIssue {
|
|
Object.defineProperty(issue, "pathSegments", {
|
|
value: [...pathSegments],
|
|
enumerable: false,
|
|
});
|
|
return issue;
|
|
}
|
|
|
|
function lookupJsonSchemaNode(
|
|
schema: unknown,
|
|
pathSegments: readonly ConfigPathSegment[],
|
|
): JsonSchemaLike | null {
|
|
let current = asNullableObjectRecord(schema);
|
|
for (const segment of pathSegments) {
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
if (typeof segment === "number") {
|
|
const items = current.items;
|
|
if (Array.isArray(items)) {
|
|
current = asNullableObjectRecord(items[segment] ?? items[0]);
|
|
continue;
|
|
}
|
|
current = asNullableObjectRecord(items);
|
|
continue;
|
|
}
|
|
const properties = asNullableObjectRecord(current.properties);
|
|
const next =
|
|
(properties && asNullableObjectRecord(properties[segment])) ||
|
|
asNullableObjectRecord(current.additionalProperties);
|
|
current = next;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function collectAllowedValuesFromJsonSchemaNode(schema: unknown): AllowedValuesCollection {
|
|
const node = asNullableObjectRecord(schema);
|
|
if (!node) {
|
|
return { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
if (Object.hasOwn(node, "const")) {
|
|
return { values: [node.const], incomplete: false, hasValues: true };
|
|
}
|
|
if (Array.isArray(node.enum)) {
|
|
return { values: node.enum, incomplete: false, hasValues: node.enum.length > 0 };
|
|
}
|
|
const type = node.type;
|
|
if (type === "boolean" || (Array.isArray(type) && type.includes("boolean"))) {
|
|
return { values: [true, false], incomplete: false, hasValues: true };
|
|
}
|
|
const unionBranches = Array.isArray(node.anyOf)
|
|
? node.anyOf
|
|
: Array.isArray(node.oneOf)
|
|
? node.oneOf
|
|
: null;
|
|
if (!unionBranches) {
|
|
return { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
const collected: unknown[] = [];
|
|
for (const branch of unionBranches) {
|
|
const branchCollected = collectAllowedValuesFromJsonSchemaNode(branch);
|
|
if (branchCollected.incomplete || !branchCollected.hasValues) {
|
|
return { values: [], incomplete: true, hasValues: false };
|
|
}
|
|
collected.push(...branchCollected.values);
|
|
}
|
|
return { values: collected, incomplete: false, hasValues: collected.length > 0 };
|
|
}
|
|
|
|
function collectAllowedValuesFromBundledChannelSchemaPath(
|
|
pathSegments: readonly ConfigPathSegment[],
|
|
): AllowedValuesCollection {
|
|
if (pathSegments[0] !== "channels" || typeof pathSegments[1] !== "string") {
|
|
return { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
const channelSchema = bundledChannelSchemaById.get(pathSegments[1]);
|
|
if (!channelSchema) {
|
|
return { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
const targetNode = lookupJsonSchemaNode(channelSchema, pathSegments.slice(2));
|
|
return targetNode
|
|
? collectAllowedValuesFromJsonSchemaNode(targetNode)
|
|
: { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
|
|
function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection {
|
|
const message = typeof record.message === "string" ? record.message : "";
|
|
const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE);
|
|
if (expectedMatch?.[1]) {
|
|
const values = [...expectedMatch[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
|
return { values, incomplete: false, hasValues: values.length > 0 };
|
|
}
|
|
|
|
// Custom Zod issues usually come from superRefine rules, but some normalized
|
|
// channel unions collapse to a generic custom issue. Use generated channel
|
|
// config metadata here so we can recover enum hints without touching runtime
|
|
// plugin registries during validation formatting.
|
|
return collectAllowedValuesFromBundledChannelSchemaPath(toConfigPathSegments(record.path));
|
|
}
|
|
|
|
function appendNumericBoundHint(message: string, record: UnknownIssueRecord): string {
|
|
// Numeric ceiling/floor hints (too_big / too_small with numeric origin).
|
|
// Append a parenthesized bound alongside Zod's native message,
|
|
// matching the clarity that enum/union rejections get via (allowed: …).
|
|
const origin = typeof record.origin === "string" ? record.origin : "";
|
|
if (origin !== "number") {
|
|
return message;
|
|
}
|
|
const inclusive = record.inclusive === true;
|
|
if (record.code === "too_big") {
|
|
const maximum = typeof record.maximum === "number" ? record.maximum : undefined;
|
|
if (maximum !== undefined) {
|
|
return inclusive
|
|
? `${message} (maximum: ${maximum})`
|
|
: `${message} (must be less than ${maximum})`;
|
|
}
|
|
}
|
|
if (record.code === "too_small") {
|
|
const minimum = typeof record.minimum === "number" ? record.minimum : undefined;
|
|
if (minimum !== undefined) {
|
|
return inclusive
|
|
? `${message} (minimum: ${minimum})`
|
|
: `${message} (must be greater than ${minimum})`;
|
|
}
|
|
}
|
|
return message;
|
|
}
|
|
|
|
function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {
|
|
const record = asNullableObjectRecord(issue);
|
|
if (!record) {
|
|
return { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
const code = typeof record.code === "string" ? record.code : "";
|
|
if (code === "invalid_value") {
|
|
const values = record.values;
|
|
return Array.isArray(values)
|
|
? { values, incomplete: false, hasValues: values.length > 0 }
|
|
: { values: [], incomplete: true, hasValues: false };
|
|
}
|
|
if (code === "invalid_type") {
|
|
return record.expected === "boolean"
|
|
? { values: [true, false], incomplete: false, hasValues: true }
|
|
: { values: [], incomplete: true, hasValues: false };
|
|
}
|
|
if (code === "custom") {
|
|
return collectAllowedValuesFromCustomIssue(record);
|
|
}
|
|
if (code !== "invalid_union") {
|
|
return { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
const nested = record.errors;
|
|
if (!Array.isArray(nested) || nested.length === 0) {
|
|
return { values: [], incomplete: true, hasValues: false };
|
|
}
|
|
const collected: unknown[] = [];
|
|
for (const branch of nested) {
|
|
if (!Array.isArray(branch) || branch.length === 0) {
|
|
return { values: [], incomplete: true, hasValues: false };
|
|
}
|
|
const branchCollected = collectAllowedValuesFromIssueList(branch);
|
|
if (branchCollected.incomplete || !branchCollected.hasValues) {
|
|
return { values: [], incomplete: true, hasValues: false };
|
|
}
|
|
collected.push(...branchCollected.values);
|
|
}
|
|
return { values: collected, incomplete: false, hasValues: collected.length > 0 };
|
|
}
|
|
|
|
function collectAllowedValuesFromIssueList(
|
|
issues: ReadonlyArray<unknown>,
|
|
): AllowedValuesCollection {
|
|
const collected: unknown[] = [];
|
|
let hasValues = false;
|
|
for (const issue of issues) {
|
|
const branch = collectAllowedValuesFromIssue(issue);
|
|
if (branch.incomplete) {
|
|
return { values: [], incomplete: true, hasValues: false };
|
|
}
|
|
if (branch.hasValues) {
|
|
hasValues = true;
|
|
collected.push(...branch.values);
|
|
}
|
|
}
|
|
return { values: collected, incomplete: false, hasValues };
|
|
}
|
|
|
|
function collectAllowedValuesFromUnknownIssue(issue: unknown): unknown[] {
|
|
const collection = collectAllowedValuesFromIssue(issue);
|
|
return collection.incomplete || !collection.hasValues ? [] : collection.values;
|
|
}
|
|
|
|
function isBindingsIssuePath(pathSegments: readonly ConfigPathSegment[]): boolean {
|
|
return pathSegments[0] === "bindings" && typeof pathSegments[1] === "number";
|
|
}
|
|
|
|
function isRouteTypeMismatchIssue(issue: UnknownIssueRecord): boolean {
|
|
const issuePath = toConfigPathSegments(issue.path);
|
|
return (
|
|
issuePath.length === 1 &&
|
|
issuePath[0] === "type" &&
|
|
issue.code === "invalid_value" &&
|
|
Array.isArray(issue.values) &&
|
|
issue.values.includes("route")
|
|
);
|
|
}
|
|
|
|
function extractBindingsSpecificUnionIssue(
|
|
record: UnknownIssueRecord,
|
|
parentPathSegments: readonly ConfigPathSegment[],
|
|
): ConfigValidationIssue | null {
|
|
if (!isBindingsIssuePath(toConfigPathSegments(record.path)) || !Array.isArray(record.errors)) {
|
|
return null;
|
|
}
|
|
let matchingBranchIssue: UnknownIssueRecord | null = null;
|
|
let matchingBranchIsUnrecognized = false;
|
|
let matchingBranchPathLen = -1;
|
|
let sawRouteTypeMismatch = false;
|
|
for (const errGroup of record.errors) {
|
|
if (!Array.isArray(errGroup)) {
|
|
continue;
|
|
}
|
|
const branch = errGroup.map(asNullableObjectRecord).filter(Boolean) as UnknownIssueRecord[];
|
|
if (branch.length === 0) {
|
|
continue;
|
|
}
|
|
if (branch.some(isRouteTypeMismatchIssue)) {
|
|
sawRouteTypeMismatch = true;
|
|
continue;
|
|
}
|
|
let branchBestIssue: UnknownIssueRecord | null = null;
|
|
let branchBestIsUnrecognized = false;
|
|
let branchBestPathLen = -1;
|
|
for (const issue of branch) {
|
|
const issuePathLen = toConfigPathSegments(issue.path).length;
|
|
const issueIsUnrecognized = issue.code === "unrecognized_keys";
|
|
if (
|
|
issuePathLen > branchBestPathLen ||
|
|
(issuePathLen === branchBestPathLen && issueIsUnrecognized && !branchBestIsUnrecognized)
|
|
) {
|
|
branchBestIssue = issue;
|
|
branchBestIsUnrecognized = issueIsUnrecognized;
|
|
branchBestPathLen = issuePathLen;
|
|
}
|
|
}
|
|
if (!branchBestIssue) {
|
|
continue;
|
|
}
|
|
if (matchingBranchIssue) {
|
|
return null;
|
|
}
|
|
matchingBranchIssue = branchBestIssue;
|
|
matchingBranchIsUnrecognized = branchBestIsUnrecognized;
|
|
matchingBranchPathLen = branchBestPathLen;
|
|
}
|
|
if (
|
|
!sawRouteTypeMismatch ||
|
|
!matchingBranchIssue ||
|
|
(matchingBranchPathLen === 0 && !matchingBranchIsUnrecognized)
|
|
) {
|
|
return null;
|
|
}
|
|
const fullPathSegments = [
|
|
...parentPathSegments,
|
|
...toConfigPathSegments(matchingBranchIssue.path),
|
|
];
|
|
const message =
|
|
typeof matchingBranchIssue.message === "string" ? matchingBranchIssue.message : "Invalid input";
|
|
return withConfigIssuePath(
|
|
{ path: formatConfigPath(fullPathSegments), message },
|
|
fullPathSegments,
|
|
);
|
|
}
|
|
|
|
export function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
|
|
const record = asNullableObjectRecord(issue);
|
|
const pathSegments = toConfigPathSegments(record?.path);
|
|
const path = formatConfigPath(pathSegments);
|
|
const message = typeof record?.message === "string" ? record.message : "Invalid input";
|
|
const enrichedMessage = record ? appendNumericBoundHint(message, record) : message;
|
|
const allowedValuesSummary = summarizeAllowedValues(collectAllowedValuesFromUnknownIssue(issue));
|
|
|
|
// Bindings use a plain union because legacy route bindings may omit `type`.
|
|
// When an explicit ACP binding fails strict-object checks, Zod collapses the
|
|
// useful ACP branch issue behind a generic union-level "Invalid input".
|
|
if (record?.code === "invalid_union" && !allowedValuesSummary) {
|
|
const betterIssue = extractBindingsSpecificUnionIssue(record, pathSegments);
|
|
if (betterIssue) {
|
|
return betterIssue;
|
|
}
|
|
}
|
|
if (!allowedValuesSummary) {
|
|
return withConfigIssuePath({ path, message: enrichedMessage }, pathSegments);
|
|
}
|
|
return withConfigIssuePath(
|
|
{
|
|
path,
|
|
message: appendAllowedValuesHint(enrichedMessage, allowedValuesSummary),
|
|
allowedValues: allowedValuesSummary.values,
|
|
allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
|
|
},
|
|
pathSegments,
|
|
);
|
|
}
|
|
|
|
function isObjectSecretRefCandidate(value: unknown): boolean {
|
|
return isRecord(value) && Boolean(coerceSecretRef(value));
|
|
}
|
|
|
|
function formatUnsupportedMutableSecretRefMessage(path: string): string {
|
|
return [
|
|
`SecretRef objects are not supported at ${path}.`,
|
|
"This credential is runtime-mutable or runtime-managed and must stay a plain string value.",
|
|
'Use a plain string (env template strings like "${MY_VAR}" are allowed).',
|
|
`See ${SECRETREF_POLICY_DOC_URL}.`,
|
|
].join(" ");
|
|
}
|
|
|
|
function collectUnsupportedMutableSecretRefIssues(raw: unknown): ConfigValidationIssue[] {
|
|
const issues: ConfigValidationIssue[] = [];
|
|
for (const candidate of unsupportedSecretRefSurfacePolicy.collectConfigCandidates(raw)) {
|
|
if (isObjectSecretRefCandidate(candidate.value)) {
|
|
issues.push({
|
|
path: candidate.path,
|
|
message: formatUnsupportedMutableSecretRefMessage(candidate.path),
|
|
});
|
|
}
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
function formatFilteredUnrecognizedKeyMessage(message: string, keys: string[]): string {
|
|
const quotedKeys = keys.map((key) => `"${key}"`).join(", ");
|
|
if (/must not have additional properties/i.test(message)) {
|
|
return `must not have additional properties: ${quotedKeys}`;
|
|
}
|
|
return keys.length === 1 ? `Unrecognized key: ${quotedKeys}` : `Unrecognized keys: ${quotedKeys}`;
|
|
}
|
|
|
|
function filterUnsupportedMutableSecretRefSchemaIssue(params: {
|
|
issue: ConfigValidationIssue;
|
|
policyIssue: ConfigValidationIssue;
|
|
}): ConfigValidationIssue | null {
|
|
const { issue, policyIssue } = params;
|
|
if (issue.path === policyIssue.path) {
|
|
return /expected string, received object/i.test(issue.message) ? null : issue;
|
|
}
|
|
if (!issue.path || !policyIssue.path || !policyIssue.path.startsWith(`${issue.path}.`)) {
|
|
return issue;
|
|
}
|
|
const childKey = policyIssue.path.slice(issue.path.length + 1).split(".")[0];
|
|
if (!childKey || !/Unrecognized key|must not have additional properties/i.test(issue.message)) {
|
|
return issue;
|
|
}
|
|
const unrecognizedKeys = [...issue.message.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
|
if (!unrecognizedKeys.includes(childKey)) {
|
|
return issue;
|
|
}
|
|
const remainingKeys = unrecognizedKeys.filter(
|
|
(key): key is string => key !== undefined && key !== childKey,
|
|
);
|
|
return remainingKeys.length === 0
|
|
? null
|
|
: { ...issue, message: formatFilteredUnrecognizedKeyMessage(issue.message, remainingKeys) };
|
|
}
|
|
|
|
export function mergeUnsupportedMutableSecretRefIssues(
|
|
policyIssues: ConfigValidationIssue[],
|
|
schemaIssues: ConfigValidationIssue[],
|
|
): ConfigValidationIssue[] {
|
|
if (policyIssues.length === 0) {
|
|
return schemaIssues;
|
|
}
|
|
const filteredSchemaIssues = schemaIssues.flatMap((issue) => {
|
|
let filteredIssue: ConfigValidationIssue | null = issue;
|
|
for (const policyIssue of policyIssues) {
|
|
if (!filteredIssue) {
|
|
return [];
|
|
}
|
|
filteredIssue = filterUnsupportedMutableSecretRefSchemaIssue({
|
|
issue: filteredIssue,
|
|
policyIssue,
|
|
});
|
|
}
|
|
return filteredIssue ? [filteredIssue] : [];
|
|
});
|
|
return [...policyIssues, ...filteredSchemaIssues];
|
|
}
|
|
|
|
export function collectUnsupportedSecretRefPolicyIssues(raw: unknown): ConfigValidationIssue[] {
|
|
return collectUnsupportedMutableSecretRefIssues(raw);
|
|
}
|