import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/ids.js"; import { isPathInside } from "../infra/path-guards.js"; import { planManifestModelCatalogSuppressions } from "../model-catalog/index.js"; import { withBundledPluginAllowlistCompat } from "../plugins/bundled-compat.js"; import { normalizePluginsConfig, normalizePluginId, resolveEffectivePluginActivationState, resolveMemorySlotDecision, } from "../plugins/config-state.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 { getOfficialExternalPluginCatalogEntry, resolveOfficialExternalPluginInstall, } from "../plugins/official-external-plugin-catalog.js"; import { loadPluginMetadataSnapshot, type PluginMetadataSnapshot, } from "../plugins/plugin-metadata-snapshot.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { hasKind } from "../plugins/slots.js"; import { resolveWebSearchInstallCatalogEntries } from "../plugins/web-search-install-catalog.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, resolveUserPath } 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 { materializeRuntimeConfig } from "./materialize.js"; import { collectConfiguredModelRefs } from "./model-refs.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"]); const BLOCKED_PLUGIN_CANDIDATE_PREFIX = "blocked plugin candidate:"; type UnknownIssueRecord = Record; type ConfigPathSegment = string | number; type AllowedValuesCollection = { values: unknown[]; incomplete: boolean; hasValues: boolean; }; type JsonSchemaLike = Record; 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( 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 formatMissingOfficialExternalPluginWarning(pluginId: string): string | null { const catalogEntry = getOfficialExternalPluginCatalogEntry(pluginId); if (!catalogEntry) { return null; } const install = resolveOfficialExternalPluginInstall(catalogEntry); const npmSpec = install?.npmSpec?.trim(); const clawhubSpec = install?.clawhubSpec?.trim(); const installSpec = install?.defaultChoice === "clawhub" ? (clawhubSpec ?? npmSpec) : (npmSpec ?? clawhubSpec); if (!installSpec) { return null; } return `plugin not installed: ${pluginId} — install the official external plugin with: openclaw plugins install ${installSpec}`; } 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 collectRawBundledChannelConfigIssues(config: OpenClawConfig): ConfigValidationIssue[] { if (!config.channels || !isRecord(config.channels)) { return []; } const issues: ConfigValidationIssue[] = []; for (const [channelId, schema] of bundledChannelSchemaById) { if (!Object.prototype.hasOwnProperty.call(config.channels, channelId)) { continue; } const result = validateJsonSchemaValue({ schema: schema as Record, cacheKey: `raw-channel:${channelId}`, value: config.channels[channelId], applyDefaults: false, }); if (result.ok) { continue; } for (const error of result.errors) { const message = error.additionalProperty ? `${error.message}: "${error.additionalProperty}"` : error.message; issues.push({ path: error.path === "" ? `channels.${channelId}` : `channels.${channelId}.${error.path}`, message: `invalid config: ${message}`, allowedValues: error.allowedValues, allowedValuesHiddenCount: error.allowedValuesHiddenCount, }); } } return issues; } 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, ): 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); } 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?: { sourceRaw?: unknown; touchedPaths?: ReadonlyArray>; validateBundledChannels?: boolean; }, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { const normalizedRaw = stripDeprecatedValidationKeys(raw); const policyIssues = collectUnsupportedSecretRefPolicyIssues(normalizedRaw); const validated = OpenClawSchema.safeParse(normalizedRaw); if (!validated.success) { const schemaIssues = validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue)); return { ok: false, issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues), }; } const validatedConfig = validated.data as OpenClawConfig; const channelIssues = policyIssues.length > 0 || opts?.validateBundledChannels ? collectRawBundledChannelConfigIssues(validatedConfig) : []; if (channelIssues.length > 0) { return { ok: false, issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, channelIssues), }; } if (policyIssues.length > 0) { return { ok: false, issues: policyIssues }; } 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, opts?: { manifestRegistry?: Pick["manifestRegistry"]; sourceRaw?: unknown; }, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { const result = validateConfigObjectRaw(raw, opts); if (!result.ok) { return result; } return { ok: true, config: materializeRuntimeConfig(result.config, "snapshot", { manifestRegistry: opts?.manifestRegistry, }), }; } type ValidateConfigWithPluginsResult = | { ok: true; config: OpenClawConfig; warnings: ConfigValidationIssue[]; } | { ok: false; issues: ConfigValidationIssue[]; warnings: ConfigValidationIssue[]; }; type ValidateConfigWithPluginsParams = { env?: NodeJS.ProcessEnv; pluginValidation?: "full" | "skip"; pluginMetadataSnapshot?: Pick; loadPluginMetadataSnapshot?: ( config: OpenClawConfig, ) => Pick; sourceRaw?: unknown; }; 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, sourceRaw: params?.sourceRaw, }); } 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, sourceRaw: params?.sourceRaw, }); } function validateConfigObjectWithPluginsBase( raw: unknown, opts: ValidateConfigWithPluginsParams & { applyDefaults: boolean }, ): ValidateConfigWithPluginsResult { const base = validateConfigObjectRaw(raw, { sourceRaw: opts.sourceRaw }); if (!base.ok) { return { ok: false, issues: base.issues, warnings: [] }; } let registryInfo: RegistryInfo | null = opts.pluginMetadataSnapshot ? { registry: opts.pluginMetadataSnapshot.manifestRegistry } : null; if (opts.applyDefaults && !registryInfo && opts.pluginValidation !== "skip") { const pluginMetadataSnapshot = opts.loadPluginMetadataSnapshot?.(base.config); if (pluginMetadataSnapshot) { registryInfo = { registry: pluginMetadataSnapshot.manifestRegistry }; } } const config = opts.applyDefaults ? materializeRuntimeConfig(base.config, "snapshot", { manifestRegistry: registryInfo?.registry, }) : 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 === "") { return base; } return `${base}.${errorPath}`; }; type RegistryInfo = { registry: PluginManifestRegistry; knownIds?: Set; overriddenPluginIds?: Set; normalizedPlugins?: ReturnType; channelSchemas?: Map< string, { schema?: Record; } >; }; let compatConfig: OpenClawConfig | null | undefined; let compatPluginIds: ReadonlySet | 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 = loadPluginMetadataSnapshot({ config, workspaceDir: workspaceDir ?? undefined, env: opts.env ?? process.env, }).manifestRegistry; registryInfo = { registry }; return registryInfo; }; const ensureCompatPluginIds = (): ReadonlySet => { if (compatPluginIdsResolved) { return compatPluginIds ?? new Set(); } compatPluginIdsResolved = true; const allow = config.plugins?.allow; if (!Array.isArray(allow) || allow.length === 0) { compatPluginIds = new Set(); 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 => { const info = ensureRegistry(); if (!info.knownIds) { info.knownIds = new Set(info.registry.plugins.map((record) => record.id)); } return info.knownIds; }; const ensureOverriddenPluginIds = (): Set => { 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 => { const info = ensureRegistry(); if (!info.normalizedPlugins) { info.normalizedPlugins = normalizePluginsConfig(ensureCompatConfig().plugins); } return info.normalizedPlugins; }; const ensureChannelSchemas = (): Map< string, { schema?: Record; } > => { 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 | undefined; const ensureInstalledPluginRecordIds = (): Set => { 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 collectActiveWebSearchProviderIds = (): string[] => { const { registry } = ensureRegistry(); return [ ...new Set( registry.plugins .flatMap((record) => record.contracts?.webSearchProviders ?? []) .map((providerId) => providerId.trim()) .filter((providerId) => providerId.length > 0), ), ].toSorted((left, right) => left.localeCompare(right)); }; const collectKnownWebSearchProviderIds = (): string[] => { return [ ...new Set([ ...collectActiveWebSearchProviderIds(), ...resolveWebSearchInstallCatalogEntries() .map((entry) => entry.provider.id.trim()) .filter((providerId) => providerId.length > 0), ]), ].toSorted((left, right) => left.localeCompare(right)); }; const hasStalePluginEvidenceForUnknownWebSearchProvider = (providerId: string): boolean => { const normalizedProviderId = normalizePluginId(providerId); if (!normalizedProviderId || ensureKnownIds().has(normalizedProviderId)) { return false; } const pluginConfig = config.plugins; if ( Array.isArray(pluginConfig?.allow) && pluginConfig.allow.some((pluginId) => normalizePluginId(pluginId) === normalizedProviderId) ) { return true; } if ( isRecord(pluginConfig?.entries) && Object.keys(pluginConfig.entries).some( (pluginId) => normalizePluginId(pluginId) === normalizedProviderId, ) ) { return true; } if ( isRecord(pluginConfig?.installs) && Object.keys(pluginConfig.installs).some( (pluginId) => normalizePluginId(pluginId) === normalizedProviderId, ) ) { return true; } return ensureInstalledPluginRecordIds().has(normalizedProviderId); }; const validateWebSearchProvider = () => { const provider = config.tools?.web?.search?.provider; if (typeof provider !== "string") { return; } const trimmed = provider.trim(); const path = "tools.web.search.provider"; if (!trimmed) { issues.push({ path, message: "web_search provider must not be empty" }); return; } const activeProviderIds = collectActiveWebSearchProviderIds(); if (activeProviderIds.includes(trimmed)) { return; } const installCatalogEntry = resolveWebSearchInstallCatalogEntries().find( (entry) => entry.provider.id === trimmed, ); if (installCatalogEntry) { issues.push({ path, message: `web_search provider is not available: ${trimmed} (install or enable plugin "${installCatalogEntry.pluginId}", then run openclaw doctor --fix)`, allowedValues: collectKnownWebSearchProviderIds(), }); return; } const allowedValues = collectKnownWebSearchProviderIds(); if (allowedValues.length === 0) { return; } const issue = { path, message: `unknown web_search provider: ${trimmed}`, allowedValues, }; if (hasStalePluginEvidenceForUnknownWebSearchProvider(trimmed)) { warnings.push({ ...issue, message: `${issue.message} (stale web search plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)`, }); return; } issues.push(issue); }; const parseProviderModelRef = (value: string): { provider: string; model: string } | null => { const slashIndex = value.indexOf("/"); if (slashIndex <= 0 || slashIndex >= value.length - 1) { return null; } const provider = normalizeLowercaseStringOrEmpty(value.slice(0, slashIndex)); const model = normalizeLowercaseStringOrEmpty(value.slice(slashIndex + 1)); return provider && model ? { provider, model } : null; }; const validateConfiguredModelRefs = () => { const configuredRefs = collectConfiguredModelRefs(config); if (configuredRefs.length === 0) { return; } const { registry } = ensureRegistry(); const suppressedModels = new Map< string, { provider: string; model: string; reason?: string } >(); for (const suppression of planManifestModelCatalogSuppressions({ registry }).suppressions) { if (suppression.when) { continue; } const key = `${suppression.provider}/${suppression.model}`; if (!suppressedModels.has(key)) { suppressedModels.set(key, { provider: suppression.provider, model: suppression.model, ...(suppression.reason ? { reason: suppression.reason } : {}), }); } } if (suppressedModels.size === 0) { return; } const seen = new Set(); for (const ref of configuredRefs) { const parsed = parseProviderModelRef(ref.value); if (!parsed) { continue; } const suppression = suppressedModels.get(`${parsed.provider}/${parsed.model}`); if (!suppression) { continue; } const issueKey = `${ref.path}\0${parsed.provider}/${parsed.model}`; if (seen.has(issueKey)) { continue; } seen.add(issueKey); const modelRef = `${suppression.provider}/${suppression.model}`; issues.push({ path: ref.path, message: suppression.reason ? `Unknown model: ${modelRef}. ${suppression.reason}` : `Unknown model: ${modelRef}.`, }); } }; const replaceChannelConfig = (channelId: string, nextValue: unknown) => { if (!channelsCloned) { mutatedConfig = { ...mutatedConfig, channels: { ...mutatedConfig.channels, }, }; channelsCloned = true; } (mutatedConfig.channels as Record)[channelId] = nextValue; }; const replacePluginEntryConfig = (pluginId: string, nextValue: Record) => { 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(["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 === "" ? `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(); 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`); } } validateWebSearchProvider(); validateConfiguredModelRefs(); 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 blockedPluginDiagnostics = new Map(); const blockedPluginDiagnosticsWithSource: Array<{ message: string; source: string }> = []; const normalizeBlockedDiagnosticPath = (value: string | undefined): string => { const trimmed = value?.trim(); if (!trimmed) { return ""; } try { return path.resolve(resolveUserPath(trimmed, opts.env ?? process.env)); } catch { return path.resolve(trimmed); } }; for (const diag of registry.diagnostics) { if (!diag.message.startsWith(BLOCKED_PLUGIN_CANDIDATE_PREFIX)) { continue; } if (!diag.pluginId && diag.source) { blockedPluginDiagnosticsWithSource.push({ message: diag.message, source: diag.source, }); } if (diag.pluginId) { const normalizedPluginId = normalizePluginId(diag.pluginId); for (const key of [diag.pluginId, normalizedPluginId]) { if (!key || blockedPluginDiagnostics.has(key)) { continue; } blockedPluginDiagnostics.set(key, { message: diag.message, ...(diag.source ? { source: diag.source } : {}), }); } } } const blockedDiagnosticSourceMatchesPluginId = ( diagnostic: { message: string; source: string }, pluginId: string, ): boolean => { const normalizedPluginId = normalizePluginId(pluginId); if (!normalizedPluginId) { return false; } const sourcePath = normalizeBlockedDiagnosticPath(diagnostic.source); if (!sourcePath) { return false; } if ( normalizePluginId(path.basename(sourcePath)) === normalizedPluginId || normalizePluginId(path.basename(path.dirname(sourcePath))) === normalizedPluginId ) { return true; } const loadPaths = config.plugins?.load?.paths; if (!Array.isArray(loadPaths)) { return false; } for (const loadPath of loadPaths) { if (typeof loadPath !== "string") { continue; } const resolvedLoadPath = normalizeBlockedDiagnosticPath(loadPath); if (!resolvedLoadPath) { continue; } if (normalizePluginId(path.basename(resolvedLoadPath)) !== normalizedPluginId) { continue; } if ( sourcePath === resolvedLoadPath || isPathInside(resolvedLoadPath, sourcePath) || isPathInside(sourcePath, resolvedLoadPath) ) { return true; } } return false; }; const findBlockedPluginDiagnostic = (pluginId: string) => { const direct = blockedPluginDiagnostics.get(pluginId) ?? blockedPluginDiagnostics.get(normalizePluginId(pluginId)); if (direct) { return direct; } return blockedPluginDiagnosticsWithSource.find((diagnostic) => blockedDiagnosticSourceMatchesPluginId(diagnostic, pluginId), ); }; 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; } const blockedDiagnostic = findBlockedPluginDiagnostic(pluginId); if (blockedDiagnostic) { const source = blockedDiagnostic.source ? `; source: ${blockedDiagnostic.source}` : ""; const message = `plugin present but blocked: ${pluginId} (see preceding plugin warning${source}; fix the blocked plugin path instead of removing config)`; if (opts?.warnOnly) { warnings.push({ path, message }); } else { issues.push({ path, message }); } return; } if (opts?.warnOnly) { const externalInstallWarning = formatMissingOfficialExternalPluginWarning(pluginId); if (externalInstallWarning) { warnings.push({ path, message: externalInstallWarning, }); 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(); 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); } } 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 }; }