mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 07:20:59 +00:00
The initial fix using snapshot.parsed broke configs with $include directives.
This commit adds a new 'resolved' field to ConfigFileSnapshot that contains
the config after $include and ${ENV} substitution but BEFORE runtime defaults
are applied. This is now used by config set/unset to avoid:
1. Breaking configs with $include directives
2. Leaking runtime defaults into the written config file
Also removes applyModelDefaults from writeConfigFile since runtime defaults
should only be applied when loading, not when writing.
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import path from "node:path";
|
|
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
|
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
|
|
import {
|
|
normalizePluginsConfig,
|
|
resolveEnableState,
|
|
resolveMemorySlotDecision,
|
|
} from "../plugins/config-state.js";
|
|
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
|
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
|
import { isRecord } from "../utils.js";
|
|
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
|
|
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
|
|
import { findLegacyConfigIssues } from "./legacy.js";
|
|
import { OpenClawSchema } from "./zod-schema.js";
|
|
|
|
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
const AVATAR_DATA_RE = /^data:/i;
|
|
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
|
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
|
|
|
|
function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean {
|
|
const workspaceRoot = path.resolve(workspaceDir);
|
|
const resolved = path.resolve(workspaceRoot, value);
|
|
const relative = path.relative(workspaceRoot, resolved);
|
|
if (relative === "") {
|
|
return true;
|
|
}
|
|
if (relative.startsWith("..")) {
|
|
return false;
|
|
}
|
|
return !path.isAbsolute(relative);
|
|
}
|
|
|
|
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 (AVATAR_DATA_RE.test(avatar) || AVATAR_HTTP_RE.test(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 = AVATAR_SCHEME_RE.test(avatar);
|
|
if (hasScheme && !WINDOWS_ABS_RE.test(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;
|
|
}
|
|
|
|
/**
|
|
* 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((iss) => ({
|
|
path: iss.path.join("."),
|
|
message: iss.message,
|
|
})),
|
|
};
|
|
}
|
|
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 };
|
|
}
|
|
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))),
|
|
};
|
|
}
|
|
|
|
export function validateConfigObjectWithPlugins(raw: unknown):
|
|
| {
|
|
ok: true;
|
|
config: OpenClawConfig;
|
|
warnings: ConfigValidationIssue[];
|
|
}
|
|
| {
|
|
ok: false;
|
|
issues: ConfigValidationIssue[];
|
|
warnings: ConfigValidationIssue[];
|
|
} {
|
|
const base = validateConfigObject(raw);
|
|
if (!base.ok) {
|
|
return { ok: false, issues: base.issues, warnings: [] };
|
|
}
|
|
|
|
const config = base.config;
|
|
const issues: ConfigValidationIssue[] = [];
|
|
const warnings: ConfigValidationIssue[] = [];
|
|
const pluginsConfig = config.plugins;
|
|
const normalizedPlugins = normalizePluginsConfig(pluginsConfig);
|
|
|
|
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
|
const registry = loadPluginManifestRegistry({
|
|
config,
|
|
workspaceDir: workspaceDir ?? undefined,
|
|
});
|
|
|
|
const knownIds = new Set(registry.plugins.map((record) => record.id));
|
|
|
|
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 entries = pluginsConfig?.entries;
|
|
if (entries && isRecord(entries)) {
|
|
for (const pluginId of Object.keys(entries)) {
|
|
if (!knownIds.has(pluginId)) {
|
|
issues.push({
|
|
path: `plugins.entries.${pluginId}`,
|
|
message: `plugin not found: ${pluginId}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const allow = pluginsConfig?.allow ?? [];
|
|
for (const pluginId of allow) {
|
|
if (typeof pluginId !== "string" || !pluginId.trim()) {
|
|
continue;
|
|
}
|
|
if (!knownIds.has(pluginId)) {
|
|
issues.push({
|
|
path: "plugins.allow",
|
|
message: `plugin not found: ${pluginId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const deny = pluginsConfig?.deny ?? [];
|
|
for (const pluginId of deny) {
|
|
if (typeof pluginId !== "string" || !pluginId.trim()) {
|
|
continue;
|
|
}
|
|
if (!knownIds.has(pluginId)) {
|
|
issues.push({
|
|
path: "plugins.deny",
|
|
message: `plugin not found: ${pluginId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const memorySlot = normalizedPlugins.slots.memory;
|
|
if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) {
|
|
issues.push({
|
|
path: "plugins.slots.memory",
|
|
message: `plugin not found: ${memorySlot}`,
|
|
});
|
|
}
|
|
|
|
const allowedChannels = new Set<string>(["defaults", ...CHANNEL_IDS]);
|
|
for (const record of registry.plugins) {
|
|
for (const channelId of record.channels) {
|
|
allowedChannels.add(channelId);
|
|
}
|
|
}
|
|
|
|
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)) {
|
|
issues.push({
|
|
path: `channels.${trimmed}`,
|
|
message: `unknown channel id: ${trimmed}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const heartbeatChannelIds = new Set<string>();
|
|
for (const channelId of CHANNEL_IDS) {
|
|
heartbeatChannelIds.add(channelId.toLowerCase());
|
|
}
|
|
for (const record of registry.plugins) {
|
|
for (const channelId of record.channels) {
|
|
const trimmed = channelId.trim();
|
|
if (trimmed) {
|
|
heartbeatChannelIds.add(trimmed.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)) {
|
|
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`);
|
|
}
|
|
}
|
|
|
|
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 enableState = resolveEnableState(pluginId, record.origin, normalizedPlugins);
|
|
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: `plugins.entries.${pluginId}.config`,
|
|
message: `invalid config: ${error}`,
|
|
});
|
|
}
|
|
}
|
|
} 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 };
|
|
}
|