mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-07 07:58:36 +00:00
1327 lines
42 KiB
TypeScript
1327 lines
42 KiB
TypeScript
import path from "node:path";
|
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/ids.js";
|
|
import { withBundledPluginAllowlistCompat } from "../plugins/bundled-compat.js";
|
|
import {
|
|
normalizePluginsConfig,
|
|
normalizePluginId,
|
|
resolveEffectivePluginActivationState,
|
|
resolveMemorySlotDecision,
|
|
} from "../plugins/config-state.js";
|
|
import {
|
|
collectRelevantDoctorPluginIds,
|
|
collectRelevantDoctorPluginIdsForTouchedPaths,
|
|
listPluginDoctorLegacyConfigRules,
|
|
} from "../plugins/doctor-contract-registry.js";
|
|
import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-record-reader.js";
|
|
import { resolveManifestCommandAliasOwnerInRegistry } from "../plugins/manifest-command-aliases.js";
|
|
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
|
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
|
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
|
|
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
|
import { hasKind } from "../plugins/slots.js";
|
|
import { collectLegacySecretRefEnvMarkerCandidates } from "../secrets/legacy-secretref-env-marker.js";
|
|
import { collectUnsupportedSecretRefConfigCandidates } from "../secrets/unsupported-surface-policy.js";
|
|
import {
|
|
hasAvatarUriScheme,
|
|
isAvatarDataUrl,
|
|
isAvatarHttpUrl,
|
|
isPathWithinRoot,
|
|
isWindowsAbsolutePath,
|
|
} from "../shared/avatar-policy.js";
|
|
import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js";
|
|
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
|
import { isRecord } from "../utils.js";
|
|
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
|
|
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
|
|
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js";
|
|
import { collectChannelSchemaMetadata } from "./channel-config-metadata.js";
|
|
import { findLegacyConfigIssues } from "./legacy.js";
|
|
import { materializeRuntimeConfig } from "./materialize.js";
|
|
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
|
|
import { coerceSecretRef } from "./types.secrets.js";
|
|
import { OpenClawSchema } from "./zod-schema.js";
|
|
|
|
const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]);
|
|
|
|
type UnknownIssueRecord = Record<string, unknown>;
|
|
type ConfigPathSegment = string | number;
|
|
type AllowedValuesCollection = {
|
|
values: unknown[];
|
|
incomplete: boolean;
|
|
hasValues: boolean;
|
|
};
|
|
type JsonSchemaLike = Record<string, unknown>;
|
|
|
|
function stripDeprecatedValidationKeys(raw: unknown): unknown {
|
|
if (!isRecord(raw) || !isRecord(raw.commands) || !Object.hasOwn(raw.commands, "modelsWrite")) {
|
|
return raw;
|
|
}
|
|
const commands = { ...raw.commands };
|
|
delete commands.modelsWrite;
|
|
return {
|
|
...raw,
|
|
commands,
|
|
};
|
|
}
|
|
|
|
const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
|
|
const SECRETREF_POLICY_DOC_URL = "https://docs.openclaw.ai/reference/secretref-credential-surface";
|
|
const bundledChannelSchemaById = new Map<string, unknown>(
|
|
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.map(
|
|
(entry) => [entry.channelId, entry.schema] as const,
|
|
),
|
|
);
|
|
|
|
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
|
|
if (!value || typeof value !== "object") {
|
|
return null;
|
|
}
|
|
return value as UnknownIssueRecord;
|
|
}
|
|
|
|
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(".");
|
|
}
|
|
|
|
function asJsonSchemaLike(value: unknown): JsonSchemaLike | null {
|
|
return value && typeof value === "object" ? (value as JsonSchemaLike) : null;
|
|
}
|
|
|
|
function lookupJsonSchemaNode(
|
|
schema: unknown,
|
|
pathSegments: readonly ConfigPathSegment[],
|
|
): JsonSchemaLike | null {
|
|
let current = asJsonSchemaLike(schema);
|
|
for (const segment of pathSegments) {
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
if (typeof segment === "number") {
|
|
const items = current.items;
|
|
if (Array.isArray(items)) {
|
|
current = asJsonSchemaLike(items[segment] ?? items[0]);
|
|
continue;
|
|
}
|
|
current = asJsonSchemaLike(items);
|
|
continue;
|
|
}
|
|
const properties = asJsonSchemaLike(current.properties);
|
|
const next =
|
|
(properties && asJsonSchemaLike(properties[segment])) ||
|
|
asJsonSchemaLike(current.additionalProperties);
|
|
current = next;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function collectAllowedValuesFromJsonSchemaNode(schema: unknown): AllowedValuesCollection {
|
|
const node = asJsonSchemaLike(schema);
|
|
if (!node) {
|
|
return { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(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") {
|
|
return { values: [true, false], incomplete: false, hasValues: true };
|
|
}
|
|
if (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));
|
|
if (!targetNode) {
|
|
return { values: [], incomplete: false, hasValues: false };
|
|
}
|
|
return collectAllowedValuesFromJsonSchemaNode(targetNode);
|
|
}
|
|
|
|
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 collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {
|
|
const record = toIssueRecord(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;
|
|
if (!Array.isArray(values)) {
|
|
return { values: [], incomplete: true, hasValues: false };
|
|
}
|
|
return { values, incomplete: false, hasValues: values.length > 0 };
|
|
}
|
|
|
|
if (code === "invalid_type") {
|
|
const expected = typeof record.expected === "string" ? record.expected : "";
|
|
if (expected === "boolean") {
|
|
return { values: [true, false], incomplete: false, hasValues: true };
|
|
}
|
|
return { 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) {
|
|
continue;
|
|
}
|
|
hasValues = true;
|
|
collected.push(...branch.values);
|
|
}
|
|
return { values: collected, incomplete: false, hasValues };
|
|
}
|
|
|
|
function collectAllowedValuesFromUnknownIssue(issue: unknown): unknown[] {
|
|
const collection = collectAllowedValuesFromIssue(issue);
|
|
if (collection.incomplete || !collection.hasValues) {
|
|
return [];
|
|
}
|
|
return 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);
|
|
if (issuePath.length !== 1 || issuePath[0] !== "type") {
|
|
return false;
|
|
}
|
|
if (issue.code !== "invalid_value" || !Array.isArray(issue.values)) {
|
|
return false;
|
|
}
|
|
return issue.values.includes("route");
|
|
}
|
|
|
|
function extractBindingsSpecificUnionIssue(
|
|
record: UnknownIssueRecord,
|
|
parentPath: string,
|
|
): 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((issue) => toIssueRecord(issue))
|
|
.filter(Boolean) as UnknownIssueRecord[];
|
|
if (branch.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
if (branch.some((issue) => isRouteTypeMismatchIssue(issue))) {
|
|
sawRouteTypeMismatch = true;
|
|
continue;
|
|
}
|
|
|
|
let branchBestIssue: UnknownIssueRecord | null = null;
|
|
let branchBestIsUnrecognized = false;
|
|
let branchBestPathLen = -1;
|
|
|
|
for (const issue of branch) {
|
|
const issueCode = typeof issue.code === "string" ? issue.code : "";
|
|
const issuePathLen = toConfigPathSegments(issue.path).length;
|
|
const issueIsUnrecognized = issueCode === "unrecognized_keys";
|
|
const issueIsBetter =
|
|
issuePathLen > branchBestPathLen
|
|
? true
|
|
: issuePathLen === branchBestPathLen && issueIsUnrecognized && !branchBestIsUnrecognized;
|
|
|
|
if (issueIsBetter) {
|
|
branchBestIssue = issue;
|
|
branchBestIsUnrecognized = issueIsUnrecognized;
|
|
branchBestPathLen = issuePathLen;
|
|
}
|
|
}
|
|
|
|
if (!branchBestIssue) {
|
|
continue;
|
|
}
|
|
|
|
if (matchingBranchIssue) {
|
|
return null;
|
|
}
|
|
|
|
matchingBranchIssue = branchBestIssue;
|
|
matchingBranchIsUnrecognized = branchBestIsUnrecognized;
|
|
matchingBranchPathLen = branchBestPathLen;
|
|
}
|
|
|
|
if (!sawRouteTypeMismatch || !matchingBranchIssue) {
|
|
return null;
|
|
}
|
|
|
|
if (matchingBranchPathLen === 0 && !matchingBranchIsUnrecognized) {
|
|
return null;
|
|
}
|
|
|
|
const subPath = formatConfigPath(toConfigPathSegments(matchingBranchIssue.path));
|
|
const fullPath = parentPath && subPath ? `${parentPath}.${subPath}` : parentPath || subPath;
|
|
const subMessage =
|
|
typeof matchingBranchIssue.message === "string" ? matchingBranchIssue.message : "Invalid input";
|
|
return { path: fullPath, message: subMessage };
|
|
}
|
|
|
|
function isObjectSecretRefCandidate(value: unknown): boolean {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return false;
|
|
}
|
|
return coerceSecretRef(value) !== null;
|
|
}
|
|
|
|
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 pushUnsupportedMutableSecretRefIssue(
|
|
issues: ConfigValidationIssue[],
|
|
path: string,
|
|
value: unknown,
|
|
): void {
|
|
if (!isObjectSecretRefCandidate(value)) {
|
|
return;
|
|
}
|
|
issues.push({
|
|
path,
|
|
message: formatUnsupportedMutableSecretRefMessage(path),
|
|
});
|
|
}
|
|
|
|
function collectUnsupportedMutableSecretRefIssues(raw: unknown): ConfigValidationIssue[] {
|
|
const issues: ConfigValidationIssue[] = [];
|
|
for (const candidate of collectUnsupportedSecretRefConfigCandidates(raw)) {
|
|
pushUnsupportedMutableSecretRefIssue(issues, candidate.path, candidate.value);
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
function isUnsupportedMutableSecretRefSchemaIssue(params: {
|
|
issue: ConfigValidationIssue;
|
|
policyIssue: ConfigValidationIssue;
|
|
}): boolean {
|
|
const { issue, policyIssue } = params;
|
|
if (issue.path === policyIssue.path) {
|
|
return /expected string, received object/i.test(issue.message);
|
|
}
|
|
|
|
if (!issue.path || !policyIssue.path || !policyIssue.path.startsWith(`${issue.path}.`)) {
|
|
return false;
|
|
}
|
|
|
|
const remainder = policyIssue.path.slice(issue.path.length + 1);
|
|
const childKey = remainder.split(".")[0];
|
|
if (!childKey) {
|
|
return false;
|
|
}
|
|
|
|
if (!/Unrecognized key/i.test(issue.message)) {
|
|
return false;
|
|
}
|
|
const unrecognizedKeys = [...issue.message.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
|
if (unrecognizedKeys.length === 0) {
|
|
return false;
|
|
}
|
|
return unrecognizedKeys.length === 1 && unrecognizedKeys[0] === childKey;
|
|
}
|
|
|
|
function mergeUnsupportedMutableSecretRefIssues(
|
|
policyIssues: ConfigValidationIssue[],
|
|
schemaIssues: ConfigValidationIssue[],
|
|
): ConfigValidationIssue[] {
|
|
if (policyIssues.length === 0) {
|
|
return schemaIssues;
|
|
}
|
|
const filteredSchemaIssues = schemaIssues.filter(
|
|
(issue) =>
|
|
!policyIssues.some((policyIssue) =>
|
|
isUnsupportedMutableSecretRefSchemaIssue({ issue, policyIssue }),
|
|
),
|
|
);
|
|
return [...policyIssues, ...filteredSchemaIssues];
|
|
}
|
|
|
|
export function collectUnsupportedSecretRefPolicyIssues(raw: unknown): ConfigValidationIssue[] {
|
|
return [
|
|
...collectUnsupportedMutableSecretRefIssues(raw),
|
|
...collectLegacySecretRefEnvMarkerIssues(raw),
|
|
];
|
|
}
|
|
|
|
function formatLegacySecretRefEnvMarkerMessage(candidate: {
|
|
value: string;
|
|
ref: { id: string; provider: string } | null;
|
|
}): string {
|
|
const replacement = candidate.ref
|
|
? JSON.stringify({ source: "env", provider: candidate.ref.provider, id: candidate.ref.id })
|
|
: '{"source":"env","provider":"default","id":"ENV_VAR"}';
|
|
return [
|
|
`${JSON.stringify(candidate.value)} is a legacy SecretRef marker and is not valid openclaw.json config.`,
|
|
`Use a structured SecretRef object instead, for example ${replacement}.`,
|
|
'Run "openclaw doctor --fix" to migrate valid secretref-env:<ENV_VAR> markers.',
|
|
`See ${SECRETREF_POLICY_DOC_URL}.`,
|
|
].join(" ");
|
|
}
|
|
|
|
function collectLegacySecretRefEnvMarkerIssues(raw: unknown): ConfigValidationIssue[] {
|
|
if (!isRecord(raw)) {
|
|
return [];
|
|
}
|
|
return collectLegacySecretRefEnvMarkerCandidates(raw as OpenClawConfig).map((candidate) => ({
|
|
path: candidate.path,
|
|
message: formatLegacySecretRefEnvMarkerMessage(candidate),
|
|
}));
|
|
}
|
|
|
|
function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
|
|
const record = toIssueRecord(issue);
|
|
const path = formatConfigPath(toConfigPathSegments(record?.path));
|
|
const message = typeof record?.message === "string" ? record.message : "Invalid input";
|
|
|
|
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 &&
|
|
typeof record.code === "string" &&
|
|
record.code === "invalid_union" &&
|
|
!allowedValuesSummary
|
|
) {
|
|
const betterIssue = extractBindingsSpecificUnionIssue(record, path);
|
|
if (betterIssue) {
|
|
return betterIssue;
|
|
}
|
|
}
|
|
|
|
if (!allowedValuesSummary) {
|
|
return { path, message };
|
|
}
|
|
|
|
return {
|
|
path,
|
|
message: appendAllowedValuesHint(message, allowedValuesSummary),
|
|
allowedValues: allowedValuesSummary.values,
|
|
allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
|
|
};
|
|
}
|
|
|
|
export const __testing = {
|
|
mapZodIssueToConfigIssue,
|
|
};
|
|
|
|
function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean {
|
|
const workspaceRoot = path.resolve(workspaceDir);
|
|
const resolved = path.resolve(workspaceRoot, value);
|
|
return isPathWithinRoot(workspaceRoot, resolved);
|
|
}
|
|
|
|
function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[] {
|
|
const agents = config.agents?.list;
|
|
if (!Array.isArray(agents) || agents.length === 0) {
|
|
return [];
|
|
}
|
|
const issues: ConfigValidationIssue[] = [];
|
|
for (const [index, entry] of agents.entries()) {
|
|
if (!entry || typeof entry !== "object") {
|
|
continue;
|
|
}
|
|
const avatarRaw = entry.identity?.avatar;
|
|
if (typeof avatarRaw !== "string") {
|
|
continue;
|
|
}
|
|
const avatar = avatarRaw.trim();
|
|
if (!avatar) {
|
|
continue;
|
|
}
|
|
if (isAvatarDataUrl(avatar) || isAvatarHttpUrl(avatar)) {
|
|
continue;
|
|
}
|
|
if (avatar.startsWith("~")) {
|
|
issues.push({
|
|
path: `agents.list.${index}.identity.avatar`,
|
|
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
|
|
});
|
|
continue;
|
|
}
|
|
const hasScheme = hasAvatarUriScheme(avatar);
|
|
if (hasScheme && !isWindowsAbsolutePath(avatar)) {
|
|
issues.push({
|
|
path: `agents.list.${index}.identity.avatar`,
|
|
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
|
|
});
|
|
continue;
|
|
}
|
|
const workspaceDir = resolveAgentWorkspaceDir(
|
|
config,
|
|
entry.id ?? resolveDefaultAgentId(config),
|
|
);
|
|
if (!isWorkspaceAvatarPath(avatar, workspaceDir)) {
|
|
issues.push({
|
|
path: `agents.list.${index}.identity.avatar`,
|
|
message: "identity.avatar must stay within the agent workspace.",
|
|
});
|
|
}
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationIssue[] {
|
|
const tailscaleMode = config.gateway?.tailscale?.mode ?? "off";
|
|
if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") {
|
|
return [];
|
|
}
|
|
const bindMode = config.gateway?.bind ?? "loopback";
|
|
if (bindMode === "loopback") {
|
|
return [];
|
|
}
|
|
const customBindHost = config.gateway?.customBindHost;
|
|
if (
|
|
bindMode === "custom" &&
|
|
isCanonicalDottedDecimalIPv4(customBindHost) &&
|
|
isLoopbackIpAddress(customBindHost)
|
|
) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
path: "gateway.bind",
|
|
message:
|
|
`gateway.bind must resolve to loopback when gateway.tailscale.mode=${tailscaleMode} ` +
|
|
'(use gateway.bind="loopback" or gateway.bind="custom" with gateway.customBindHost="127.0.0.1")',
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Validates config without applying runtime defaults.
|
|
* Use this when you need the raw validated config (e.g., for writing back to file).
|
|
*/
|
|
export function validateConfigObjectRaw(
|
|
raw: unknown,
|
|
opts?: {
|
|
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>;
|
|
},
|
|
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
|
|
const normalizedRaw = stripDeprecatedValidationKeys(raw);
|
|
const policyIssues = collectUnsupportedSecretRefPolicyIssues(normalizedRaw);
|
|
const doctorPluginIds = opts?.touchedPaths
|
|
? collectRelevantDoctorPluginIdsForTouchedPaths({
|
|
raw: normalizedRaw,
|
|
touchedPaths: opts.touchedPaths,
|
|
})
|
|
: collectRelevantDoctorPluginIds(normalizedRaw);
|
|
const extraLegacyRules = listPluginDoctorLegacyConfigRules({
|
|
pluginIds: doctorPluginIds,
|
|
});
|
|
const legacyIssues = findLegacyConfigIssues(
|
|
normalizedRaw,
|
|
normalizedRaw,
|
|
extraLegacyRules,
|
|
opts?.touchedPaths,
|
|
);
|
|
if (legacyIssues.length > 0) {
|
|
return {
|
|
ok: false,
|
|
issues: legacyIssues.map((iss) => ({
|
|
path: iss.path,
|
|
message: iss.message,
|
|
})),
|
|
};
|
|
}
|
|
const validated = OpenClawSchema.safeParse(normalizedRaw);
|
|
if (!validated.success) {
|
|
const schemaIssues = validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue));
|
|
return {
|
|
ok: false,
|
|
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
|
|
};
|
|
}
|
|
if (policyIssues.length > 0) {
|
|
return { ok: false, issues: policyIssues };
|
|
}
|
|
const validatedConfig = validated.data as OpenClawConfig;
|
|
const duplicates = findDuplicateAgentDirs(validatedConfig);
|
|
if (duplicates.length > 0) {
|
|
return {
|
|
ok: false,
|
|
issues: [
|
|
{
|
|
path: "agents.list",
|
|
message: formatDuplicateAgentDirError(duplicates),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
const avatarIssues = validateIdentityAvatar(validatedConfig);
|
|
if (avatarIssues.length > 0) {
|
|
return { ok: false, issues: avatarIssues };
|
|
}
|
|
const gatewayTailscaleBindIssues = validateGatewayTailscaleBind(validatedConfig);
|
|
if (gatewayTailscaleBindIssues.length > 0) {
|
|
return { ok: false, issues: gatewayTailscaleBindIssues };
|
|
}
|
|
return {
|
|
ok: true,
|
|
config: validatedConfig,
|
|
};
|
|
}
|
|
|
|
export function validateConfigObject(
|
|
raw: unknown,
|
|
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
|
|
const result = validateConfigObjectRaw(raw);
|
|
if (!result.ok) {
|
|
return result;
|
|
}
|
|
return {
|
|
ok: true,
|
|
config: materializeRuntimeConfig(result.config, "snapshot"),
|
|
};
|
|
}
|
|
|
|
type ValidateConfigWithPluginsResult =
|
|
| {
|
|
ok: true;
|
|
config: OpenClawConfig;
|
|
warnings: ConfigValidationIssue[];
|
|
}
|
|
| {
|
|
ok: false;
|
|
issues: ConfigValidationIssue[];
|
|
warnings: ConfigValidationIssue[];
|
|
};
|
|
|
|
type ValidateConfigWithPluginsParams = {
|
|
env?: NodeJS.ProcessEnv;
|
|
pluginValidation?: "full" | "skip";
|
|
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "manifestRegistry">;
|
|
loadPluginMetadataSnapshot?: (
|
|
config: OpenClawConfig,
|
|
) => Pick<PluginMetadataSnapshot, "manifestRegistry">;
|
|
};
|
|
|
|
export function validateConfigObjectWithPlugins(
|
|
raw: unknown,
|
|
params?: ValidateConfigWithPluginsParams,
|
|
): ValidateConfigWithPluginsResult {
|
|
return validateConfigObjectWithPluginsBase(raw, {
|
|
applyDefaults: true,
|
|
env: params?.env,
|
|
pluginValidation: params?.pluginValidation ?? "full",
|
|
pluginMetadataSnapshot: params?.pluginMetadataSnapshot,
|
|
loadPluginMetadataSnapshot: params?.loadPluginMetadataSnapshot,
|
|
});
|
|
}
|
|
|
|
export function validateConfigObjectRawWithPlugins(
|
|
raw: unknown,
|
|
params?: ValidateConfigWithPluginsParams,
|
|
): ValidateConfigWithPluginsResult {
|
|
return validateConfigObjectWithPluginsBase(raw, {
|
|
applyDefaults: false,
|
|
env: params?.env,
|
|
pluginValidation: params?.pluginValidation ?? "full",
|
|
pluginMetadataSnapshot: params?.pluginMetadataSnapshot,
|
|
loadPluginMetadataSnapshot: params?.loadPluginMetadataSnapshot,
|
|
});
|
|
}
|
|
|
|
function validateConfigObjectWithPluginsBase(
|
|
raw: unknown,
|
|
opts: ValidateConfigWithPluginsParams & { applyDefaults: boolean },
|
|
): ValidateConfigWithPluginsResult {
|
|
const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw);
|
|
if (!base.ok) {
|
|
return { ok: false, issues: base.issues, warnings: [] };
|
|
}
|
|
|
|
const config = base.config;
|
|
if (opts.pluginValidation === "skip") {
|
|
return {
|
|
ok: true,
|
|
config,
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
const issues: ConfigValidationIssue[] = [];
|
|
const warnings: ConfigValidationIssue[] = [];
|
|
const hasExplicitPluginsConfig =
|
|
isRecord(raw) && Object.prototype.hasOwnProperty.call(raw, "plugins");
|
|
|
|
const resolvePluginConfigIssuePath = (pluginId: string, errorPath: string): string => {
|
|
const base = `plugins.entries.${pluginId}.config`;
|
|
if (!errorPath || errorPath === "<root>") {
|
|
return base;
|
|
}
|
|
return `${base}.${errorPath}`;
|
|
};
|
|
|
|
type RegistryInfo = {
|
|
registry: PluginManifestRegistry;
|
|
knownIds?: Set<string>;
|
|
overriddenPluginIds?: Set<string>;
|
|
normalizedPlugins?: ReturnType<typeof normalizePluginsConfig>;
|
|
channelSchemas?: Map<
|
|
string,
|
|
{
|
|
schema?: Record<string, unknown>;
|
|
}
|
|
>;
|
|
};
|
|
|
|
let registryInfo: RegistryInfo | null = opts.pluginMetadataSnapshot
|
|
? { registry: opts.pluginMetadataSnapshot.manifestRegistry }
|
|
: null;
|
|
let compatConfig: OpenClawConfig | null | undefined;
|
|
let compatPluginIds: ReadonlySet<string> | null = null;
|
|
let compatPluginIdsResolved = false;
|
|
let registryDiagnosticsPushed = false;
|
|
|
|
const pushRegistryDiagnostics = (registry: PluginManifestRegistry): void => {
|
|
if (registryDiagnosticsPushed) {
|
|
return;
|
|
}
|
|
registryDiagnosticsPushed = true;
|
|
for (const diag of registry.diagnostics) {
|
|
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins";
|
|
if (!diag.pluginId && diag.message.includes("plugin path not found")) {
|
|
path = "plugins.load.paths";
|
|
}
|
|
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
|
|
const message = `${pluginLabel}: ${diag.message}`;
|
|
if (diag.level === "error") {
|
|
issues.push({ path, message });
|
|
} else {
|
|
warnings.push({ path, message });
|
|
}
|
|
}
|
|
};
|
|
|
|
const loadValidationRegistry = (): RegistryInfo => {
|
|
const pluginMetadataSnapshot = opts.loadPluginMetadataSnapshot?.(config);
|
|
if (pluginMetadataSnapshot) {
|
|
registryInfo = { registry: pluginMetadataSnapshot.manifestRegistry };
|
|
return registryInfo;
|
|
}
|
|
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
|
const registry = loadPluginManifestRegistryForPluginRegistry({
|
|
config,
|
|
workspaceDir: workspaceDir ?? undefined,
|
|
env: opts.env,
|
|
includeDisabled: true,
|
|
});
|
|
registryInfo = { registry };
|
|
return registryInfo;
|
|
};
|
|
|
|
const ensureCompatPluginIds = (): ReadonlySet<string> => {
|
|
if (compatPluginIdsResolved) {
|
|
return compatPluginIds ?? new Set<string>();
|
|
}
|
|
compatPluginIdsResolved = true;
|
|
const allow = config.plugins?.allow;
|
|
if (!Array.isArray(allow) || allow.length === 0) {
|
|
compatPluginIds = new Set<string>();
|
|
return compatPluginIds;
|
|
}
|
|
const { registry } = registryInfo ?? loadValidationRegistry();
|
|
const overriddenBundledPluginIds = new Set(
|
|
registry.diagnostics
|
|
.filter((diag) => diag.message.includes("duplicate plugin id detected"))
|
|
.map((diag) => diag.pluginId)
|
|
.filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== ""),
|
|
);
|
|
compatPluginIds = new Set(
|
|
registry.plugins
|
|
.filter(
|
|
(plugin) =>
|
|
plugin.origin === "bundled" &&
|
|
(plugin.contracts?.webSearchProviders?.length ?? 0) > 0 &&
|
|
!overriddenBundledPluginIds.has(plugin.id),
|
|
)
|
|
.map((plugin) => plugin.id),
|
|
);
|
|
return compatPluginIds;
|
|
};
|
|
|
|
const ensureCompatConfig = (): OpenClawConfig => {
|
|
if (compatConfig !== undefined) {
|
|
return compatConfig ?? config;
|
|
}
|
|
|
|
const allow = config.plugins?.allow;
|
|
if (!Array.isArray(allow) || allow.length === 0) {
|
|
compatConfig = config;
|
|
return config;
|
|
}
|
|
|
|
compatConfig = withBundledPluginAllowlistCompat({
|
|
config,
|
|
pluginIds: [...ensureCompatPluginIds()],
|
|
});
|
|
return compatConfig ?? config;
|
|
};
|
|
|
|
const ensureRegistry = (): RegistryInfo => {
|
|
const info = registryInfo ?? loadValidationRegistry();
|
|
ensureCompatConfig();
|
|
pushRegistryDiagnostics(info.registry);
|
|
return info;
|
|
};
|
|
|
|
const ensureKnownIds = (): Set<string> => {
|
|
const info = ensureRegistry();
|
|
if (!info.knownIds) {
|
|
info.knownIds = new Set(info.registry.plugins.map((record) => record.id));
|
|
}
|
|
return info.knownIds;
|
|
};
|
|
|
|
const ensureOverriddenPluginIds = (): Set<string> => {
|
|
const info = ensureRegistry();
|
|
if (!info.overriddenPluginIds) {
|
|
info.overriddenPluginIds = new Set(
|
|
info.registry.diagnostics
|
|
.filter((diag) => diag.message.includes("duplicate plugin id detected"))
|
|
.map((diag) => diag.pluginId)
|
|
.filter(
|
|
(pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== "",
|
|
),
|
|
);
|
|
}
|
|
return info.overriddenPluginIds;
|
|
};
|
|
|
|
const ensureNormalizedPlugins = (): ReturnType<typeof normalizePluginsConfig> => {
|
|
const info = ensureRegistry();
|
|
if (!info.normalizedPlugins) {
|
|
info.normalizedPlugins = normalizePluginsConfig(ensureCompatConfig().plugins);
|
|
}
|
|
return info.normalizedPlugins;
|
|
};
|
|
|
|
const ensureChannelSchemas = (): Map<
|
|
string,
|
|
{
|
|
schema?: Record<string, unknown>;
|
|
}
|
|
> => {
|
|
const info = ensureRegistry();
|
|
if (!info.channelSchemas) {
|
|
info.channelSchemas = new Map(
|
|
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.map(
|
|
(entry) => [entry.channelId, { schema: entry.schema }] as const,
|
|
),
|
|
);
|
|
for (const entry of collectChannelSchemaMetadata(info.registry)) {
|
|
const current = info.channelSchemas.get(entry.id);
|
|
if (entry.configSchema) {
|
|
info.channelSchemas.set(entry.id, { schema: entry.configSchema });
|
|
continue;
|
|
}
|
|
if (!current) {
|
|
info.channelSchemas.set(entry.id, {});
|
|
}
|
|
}
|
|
}
|
|
return info.channelSchemas;
|
|
};
|
|
|
|
let mutatedConfig = config;
|
|
let channelsCloned = false;
|
|
let pluginsCloned = false;
|
|
let pluginEntriesCloned = false;
|
|
let installedPluginRecordIds: Set<string> | undefined;
|
|
|
|
const ensureInstalledPluginRecordIds = (): Set<string> => {
|
|
if (installedPluginRecordIds) {
|
|
return installedPluginRecordIds;
|
|
}
|
|
try {
|
|
installedPluginRecordIds = new Set(
|
|
Object.keys(loadInstalledPluginIndexInstallRecordsSync({ env: opts.env })).map(
|
|
normalizePluginId,
|
|
),
|
|
);
|
|
} catch {
|
|
installedPluginRecordIds = new Set();
|
|
}
|
|
return installedPluginRecordIds;
|
|
};
|
|
|
|
const hasStalePluginEvidenceForUnknownChannel = (channelId: string): boolean => {
|
|
const normalizedChannelId = normalizePluginId(channelId);
|
|
if (!normalizedChannelId || ensureKnownIds().has(normalizedChannelId)) {
|
|
return false;
|
|
}
|
|
const pluginConfig = config.plugins;
|
|
if (
|
|
Array.isArray(pluginConfig?.allow) &&
|
|
pluginConfig.allow.some((pluginId) => normalizePluginId(pluginId) === normalizedChannelId)
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
isRecord(pluginConfig?.entries) &&
|
|
Object.keys(pluginConfig.entries).some(
|
|
(pluginId) => normalizePluginId(pluginId) === normalizedChannelId,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
isRecord(pluginConfig?.installs) &&
|
|
Object.keys(pluginConfig.installs).some(
|
|
(pluginId) => normalizePluginId(pluginId) === normalizedChannelId,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
return ensureInstalledPluginRecordIds().has(normalizedChannelId);
|
|
};
|
|
|
|
const replaceChannelConfig = (channelId: string, nextValue: unknown) => {
|
|
if (!channelsCloned) {
|
|
mutatedConfig = {
|
|
...mutatedConfig,
|
|
channels: {
|
|
...mutatedConfig.channels,
|
|
},
|
|
};
|
|
channelsCloned = true;
|
|
}
|
|
(mutatedConfig.channels as Record<string, unknown>)[channelId] = nextValue;
|
|
};
|
|
|
|
const replacePluginEntryConfig = (pluginId: string, nextValue: Record<string, unknown>) => {
|
|
if (!pluginsCloned) {
|
|
mutatedConfig = {
|
|
...mutatedConfig,
|
|
plugins: {
|
|
...mutatedConfig.plugins,
|
|
},
|
|
};
|
|
pluginsCloned = true;
|
|
}
|
|
if (!pluginEntriesCloned) {
|
|
mutatedConfig.plugins = {
|
|
...mutatedConfig.plugins,
|
|
entries: {
|
|
...mutatedConfig.plugins?.entries,
|
|
},
|
|
};
|
|
pluginEntriesCloned = true;
|
|
}
|
|
const currentEntry = mutatedConfig.plugins?.entries?.[pluginId];
|
|
mutatedConfig.plugins!.entries![pluginId] = {
|
|
...currentEntry,
|
|
config: nextValue,
|
|
};
|
|
};
|
|
|
|
const allowedChannels = new Set<string>(["defaults", "modelByChannel", ...CHANNEL_IDS]);
|
|
|
|
if (config.channels && isRecord(config.channels)) {
|
|
for (const key of Object.keys(config.channels)) {
|
|
const trimmed = key.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
if (!allowedChannels.has(trimmed)) {
|
|
const { registry } = ensureRegistry();
|
|
for (const record of registry.plugins) {
|
|
for (const channelId of record.channels) {
|
|
allowedChannels.add(channelId);
|
|
}
|
|
}
|
|
}
|
|
if (!allowedChannels.has(trimmed)) {
|
|
const issue = {
|
|
path: `channels.${trimmed}`,
|
|
message: `unknown channel id: ${trimmed}`,
|
|
};
|
|
if (hasStalePluginEvidenceForUnknownChannel(trimmed)) {
|
|
warnings.push({
|
|
...issue,
|
|
message: `${issue.message} (stale channel plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)`,
|
|
});
|
|
} else {
|
|
issues.push(issue);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const channelSchema = ensureChannelSchemas().get(trimmed)?.schema;
|
|
if (!channelSchema) {
|
|
continue;
|
|
}
|
|
const result = validateJsonSchemaValue({
|
|
schema: channelSchema,
|
|
cacheKey: `channel:${trimmed}`,
|
|
value: config.channels[trimmed],
|
|
applyDefaults: true, // Always apply defaults for AJV schema validation;
|
|
// writeConfigFile persists persistCandidate, not validated.config (#61841)
|
|
});
|
|
if (!result.ok) {
|
|
for (const error of result.errors) {
|
|
issues.push({
|
|
path:
|
|
error.path === "<root>" ? `channels.${trimmed}` : `channels.${trimmed}.${error.path}`,
|
|
message: `invalid config: ${error.message}`,
|
|
allowedValues: error.allowedValues,
|
|
allowedValuesHiddenCount: error.allowedValuesHiddenCount,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
replaceChannelConfig(trimmed, result.value);
|
|
}
|
|
}
|
|
|
|
const heartbeatChannelIds = new Set<string>();
|
|
for (const channelId of CHANNEL_IDS) {
|
|
heartbeatChannelIds.add(normalizeLowercaseStringOrEmpty(channelId));
|
|
}
|
|
|
|
const validateHeartbeatTarget = (target: string | undefined, path: string) => {
|
|
if (typeof target !== "string") {
|
|
return;
|
|
}
|
|
const trimmed = target.trim();
|
|
if (!trimmed) {
|
|
issues.push({ path, message: "heartbeat target must not be empty" });
|
|
return;
|
|
}
|
|
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
|
|
if (normalized === "last" || normalized === "none") {
|
|
return;
|
|
}
|
|
if (normalizeChatChannelId(trimmed)) {
|
|
return;
|
|
}
|
|
if (!heartbeatChannelIds.has(normalized)) {
|
|
const { registry } = ensureRegistry();
|
|
for (const record of registry.plugins) {
|
|
for (const channelId of record.channels) {
|
|
const pluginChannel = channelId.trim();
|
|
if (pluginChannel) {
|
|
heartbeatChannelIds.add(normalizeLowercaseStringOrEmpty(pluginChannel));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (heartbeatChannelIds.has(normalized)) {
|
|
return;
|
|
}
|
|
issues.push({ path, message: `unknown heartbeat target: ${target}` });
|
|
};
|
|
|
|
validateHeartbeatTarget(
|
|
config.agents?.defaults?.heartbeat?.target,
|
|
"agents.defaults.heartbeat.target",
|
|
);
|
|
if (Array.isArray(config.agents?.list)) {
|
|
for (const [index, entry] of config.agents.list.entries()) {
|
|
validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`);
|
|
}
|
|
}
|
|
|
|
if (!hasExplicitPluginsConfig) {
|
|
if (issues.length > 0) {
|
|
return { ok: false, issues, warnings };
|
|
}
|
|
return { ok: true, config: mutatedConfig, warnings };
|
|
}
|
|
|
|
const { registry } = ensureRegistry();
|
|
const knownIds = ensureKnownIds();
|
|
const normalizedPlugins = ensureNormalizedPlugins();
|
|
const effectiveConfig = ensureCompatConfig();
|
|
const pushMissingPluginIssue = (
|
|
path: string,
|
|
pluginId: string,
|
|
opts?: { warnOnly?: boolean },
|
|
) => {
|
|
if (LEGACY_REMOVED_PLUGIN_IDS.has(pluginId)) {
|
|
warnings.push({
|
|
path,
|
|
message: `plugin removed: ${pluginId} (stale config entry ignored; remove it from plugins config)`,
|
|
});
|
|
return;
|
|
}
|
|
if (opts?.warnOnly) {
|
|
warnings.push({
|
|
path,
|
|
message: `plugin not found: ${pluginId} (stale config entry ignored; remove it from plugins config)`,
|
|
});
|
|
return;
|
|
}
|
|
issues.push({
|
|
path,
|
|
message: `plugin not found: ${pluginId}`,
|
|
});
|
|
};
|
|
|
|
const pluginsConfig = config.plugins;
|
|
|
|
const entries = pluginsConfig?.entries;
|
|
if (entries && isRecord(entries)) {
|
|
for (const pluginId of Object.keys(entries)) {
|
|
if (!knownIds.has(pluginId)) {
|
|
// Keep gateway startup resilient when plugins are removed/renamed across upgrades.
|
|
pushMissingPluginIssue(`plugins.entries.${pluginId}`, pluginId, { warnOnly: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
const allow = pluginsConfig?.allow ?? [];
|
|
for (const pluginId of allow) {
|
|
if (typeof pluginId !== "string" || !pluginId.trim()) {
|
|
continue;
|
|
}
|
|
if (!knownIds.has(pluginId)) {
|
|
const commandAlias = resolveManifestCommandAliasOwnerInRegistry({
|
|
command: pluginId,
|
|
registry,
|
|
});
|
|
if (commandAlias?.pluginId && knownIds.has(commandAlias.pluginId)) {
|
|
warnings.push({
|
|
path: "plugins.allow",
|
|
message:
|
|
`"${pluginId}" is not a plugin — it is a command provided by the "${commandAlias.pluginId}" plugin. ` +
|
|
`Use "${commandAlias.pluginId}" in plugins.allow instead.`,
|
|
});
|
|
} else {
|
|
pushMissingPluginIssue("plugins.allow", pluginId, { warnOnly: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
const deny = pluginsConfig?.deny ?? [];
|
|
for (const pluginId of deny) {
|
|
if (typeof pluginId !== "string" || !pluginId.trim()) {
|
|
continue;
|
|
}
|
|
if (!knownIds.has(pluginId)) {
|
|
pushMissingPluginIssue("plugins.deny", pluginId);
|
|
}
|
|
}
|
|
|
|
// The default memory slot is inferred; only a user-configured slot should block startup.
|
|
const pluginSlots = pluginsConfig?.slots;
|
|
const hasExplicitMemorySlot =
|
|
pluginSlots !== undefined && Object.prototype.hasOwnProperty.call(pluginSlots, "memory");
|
|
const memorySlot = normalizedPlugins.slots.memory;
|
|
if (
|
|
hasExplicitMemorySlot &&
|
|
typeof memorySlot === "string" &&
|
|
memorySlot.trim() &&
|
|
!knownIds.has(memorySlot)
|
|
) {
|
|
pushMissingPluginIssue("plugins.slots.memory", memorySlot);
|
|
}
|
|
|
|
let selectedMemoryPluginId: string | null = null;
|
|
const seenPlugins = new Set<string>();
|
|
for (const record of registry.plugins) {
|
|
const pluginId = record.id;
|
|
if (seenPlugins.has(pluginId)) {
|
|
continue;
|
|
}
|
|
seenPlugins.add(pluginId);
|
|
const entry = normalizedPlugins.entries[pluginId];
|
|
const entryHasConfig = Boolean(entry?.config);
|
|
|
|
const activationState = resolveEffectivePluginActivationState({
|
|
id: pluginId,
|
|
origin: record.origin,
|
|
config: normalizedPlugins,
|
|
rootConfig: effectiveConfig,
|
|
});
|
|
let enabled = activationState.activated;
|
|
let reason = activationState.reason;
|
|
|
|
if (enabled) {
|
|
const memoryDecision = resolveMemorySlotDecision({
|
|
id: pluginId,
|
|
kind: record.kind,
|
|
slot: memorySlot,
|
|
selectedId: selectedMemoryPluginId,
|
|
});
|
|
if (!memoryDecision.enabled) {
|
|
enabled = false;
|
|
reason = memoryDecision.reason;
|
|
}
|
|
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
|
selectedMemoryPluginId = pluginId;
|
|
}
|
|
}
|
|
|
|
const shouldReplacePluginConfig = entryHasConfig || (opts.applyDefaults && enabled);
|
|
const shouldValidate = enabled || entryHasConfig;
|
|
if (shouldValidate) {
|
|
if (record.configSchema) {
|
|
const res = validateJsonSchemaValue({
|
|
schema: record.configSchema,
|
|
cacheKey: record.schemaCacheKey ?? record.manifestPath ?? pluginId,
|
|
value: entry?.config ?? {},
|
|
applyDefaults: true, // Always apply defaults for AJV schema validation;
|
|
// writeConfigFile persists persistCandidate, not validated.config (#61841)
|
|
});
|
|
if (!res.ok) {
|
|
for (const error of res.errors) {
|
|
issues.push({
|
|
path: resolvePluginConfigIssuePath(pluginId, error.path),
|
|
message: `invalid config: ${error.message}`,
|
|
allowedValues: error.allowedValues,
|
|
allowedValuesHiddenCount: error.allowedValuesHiddenCount,
|
|
});
|
|
}
|
|
} else if (shouldReplacePluginConfig) {
|
|
replacePluginEntryConfig(pluginId, res.value as Record<string, unknown>);
|
|
}
|
|
} else if (record.format === "bundle") {
|
|
// Compatible bundles currently expose no native OpenClaw config schema.
|
|
// Treat them as schema-less capability packs rather than failing validation.
|
|
} else {
|
|
issues.push({
|
|
path: `plugins.entries.${pluginId}`,
|
|
message: `plugin schema missing for ${pluginId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const suppressDisabledConfigWarning =
|
|
ensureCompatPluginIds().has(pluginId) && !ensureOverriddenPluginIds().has(pluginId);
|
|
if (!enabled && entryHasConfig && !suppressDisabledConfigWarning) {
|
|
warnings.push({
|
|
path: `plugins.entries.${pluginId}`,
|
|
message: `plugin disabled (${reason ?? "disabled"}) but config is present`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (issues.length > 0) {
|
|
return { ok: false, issues, warnings };
|
|
}
|
|
|
|
return { ok: true, config: mutatedConfig, warnings };
|
|
}
|