Files
clawdbot/src/config/validation.ts

238 lines
7.0 KiB
TypeScript

import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS } 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 { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.js";
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
import { ClawdbotSchema } from "./zod-schema.js";
export function validateConfigObject(
raw: unknown,
): { ok: true; config: ClawdbotConfig } | { 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 = ClawdbotSchema.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 ClawdbotConfig);
if (duplicates.length > 0) {
return {
ok: false,
issues: [
{
path: "agents.list",
message: formatDuplicateAgentDirError(duplicates),
},
],
};
}
return {
ok: true,
config: applyModelDefaults(
applyAgentDefaults(applySessionDefaults(validated.data as ClawdbotConfig)),
),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function validateConfigObjectWithPlugins(raw: unknown):
| {
ok: true;
config: ClawdbotConfig;
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}`,
});
}
}
}
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 };
}