import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js"; import { normalizePluginsConfig, resolveEffectiveEnableState, resolveMemorySlotDecision, } from "../plugins/config-state.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { hasAvatarUriScheme, isAvatarDataUrl, isAvatarHttpUrl, isPathWithinRoot, isWindowsAbsolutePath, } from "../shared/avatar-policy.js"; import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js"; import { isRecord } from "../utils.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js"; import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { findLegacyConfigIssues } from "./legacy.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { OpenClawSchema } from "./zod-schema.js"; const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth"]); type UnknownIssueRecord = Record; type AllowedValuesCollection = { values: unknown[]; incomplete: boolean; hasValues: boolean; }; function toIssueRecord(value: unknown): UnknownIssueRecord | null { if (!value || typeof value !== "object") { return null; } return value as UnknownIssueRecord; } 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 !== "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 mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue { const record = toIssueRecord(issue); const path = Array.isArray(record?.path) ? record.path .filter((segment): segment is string | number => { const segmentType = typeof segment; return segmentType === "string" || segmentType === "number"; }) .join(".") : ""; const message = typeof record?.message === "string" ? record.message : "Invalid input"; const allowedValuesSummary = summarizeAllowedValues(collectAllowedValuesFromUnknownIssue(issue)); if (!allowedValuesSummary) { return { path, message }; } return { path, message: appendAllowedValuesHint(message, allowedValuesSummary), allowedValues: allowedValuesSummary.values, allowedValuesHiddenCount: allowedValuesSummary.hiddenCount, }; } 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, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { const legacyIssues = findLegacyConfigIssues(raw); if (legacyIssues.length > 0) { return { ok: false, issues: legacyIssues.map((iss) => ({ path: iss.path, message: iss.message, })), }; } const validated = OpenClawSchema.safeParse(raw); if (!validated.success) { return { ok: false, issues: validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue)), }; } const duplicates = findDuplicateAgentDirs(validated.data as OpenClawConfig); if (duplicates.length > 0) { return { ok: false, issues: [ { path: "agents.list", message: formatDuplicateAgentDirError(duplicates), }, ], }; } const avatarIssues = validateIdentityAvatar(validated.data as OpenClawConfig); if (avatarIssues.length > 0) { return { ok: false, issues: avatarIssues }; } const gatewayTailscaleBindIssues = validateGatewayTailscaleBind(validated.data as OpenClawConfig); if (gatewayTailscaleBindIssues.length > 0) { return { ok: false, issues: gatewayTailscaleBindIssues }; } return { ok: true, config: validated.data as OpenClawConfig, }; } 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: applyModelDefaults(applyAgentDefaults(applySessionDefaults(result.config))), }; } type ValidateConfigWithPluginsResult = | { ok: true; config: OpenClawConfig; warnings: ConfigValidationIssue[]; } | { ok: false; issues: ConfigValidationIssue[]; warnings: ConfigValidationIssue[]; }; export function validateConfigObjectWithPlugins(raw: unknown): ValidateConfigWithPluginsResult { return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true }); } export function validateConfigObjectRawWithPlugins(raw: unknown): ValidateConfigWithPluginsResult { return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false }); } function validateConfigObjectWithPluginsBase( raw: unknown, opts: { 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; 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: ReturnType; knownIds?: Set; normalizedPlugins?: ReturnType; }; let registryInfo: RegistryInfo | null = null; const ensureRegistry = (): RegistryInfo => { if (registryInfo) { return registryInfo; } const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); const registry = loadPluginManifestRegistry({ config, workspaceDir: workspaceDir ?? undefined, }); 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 }); } } registryInfo = { registry }; return registryInfo; }; const ensureKnownIds = (): Set => { const info = ensureRegistry(); if (!info.knownIds) { info.knownIds = new Set(info.registry.plugins.map((record) => record.id)); } return info.knownIds; }; const ensureNormalizedPlugins = (): ReturnType => { const info = ensureRegistry(); if (!info.normalizedPlugins) { info.normalizedPlugins = normalizePluginsConfig(config.plugins); } return info.normalizedPlugins; }; 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)) { issues.push({ path: `channels.${trimmed}`, message: `unknown channel id: ${trimmed}`, }); } } } const heartbeatChannelIds = new Set(); for (const channelId of CHANNEL_IDS) { heartbeatChannelIds.add(channelId.toLowerCase()); } 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 = trimmed.toLowerCase(); 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(pluginChannel.toLowerCase()); } } } } 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, warnings }; } const { registry } = ensureRegistry(); const knownIds = ensureKnownIds(); const normalizedPlugins = ensureNormalizedPlugins(); 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)) { pushMissingPluginIssue("plugins.allow", pluginId); } } const deny = pluginsConfig?.deny ?? []; for (const pluginId of deny) { if (typeof pluginId !== "string" || !pluginId.trim()) { continue; } if (!knownIds.has(pluginId)) { pushMissingPluginIssue("plugins.deny", pluginId); } } const memorySlot = normalizedPlugins.slots.memory; if (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 enableState = resolveEffectiveEnableState({ id: pluginId, origin: record.origin, config: normalizedPlugins, rootConfig: config, }); let enabled = enableState.enabled; let reason = enableState.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 && record.kind === "memory") { selectedMemoryPluginId = pluginId; } } const shouldValidate = enabled || entryHasConfig; if (shouldValidate) { if (record.configSchema) { const res = validateJsonSchemaValue({ schema: record.configSchema, cacheKey: record.schemaCacheKey ?? record.manifestPath ?? pluginId, value: entry?.config ?? {}, }); 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 { issues.push({ path: `plugins.entries.${pluginId}`, message: `plugin schema missing for ${pluginId}`, }); } } if (!enabled && entryHasConfig) { 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, warnings }; }