Files
openclaw/src/config/io.write-prepare.ts
2026-04-07 08:14:35 +01:00

323 lines
9.2 KiB
TypeScript

import { isDeepStrictEqual } from "node:util";
import { isRecord } from "../utils.js";
import { applyMergePatch } from "./merge-patch.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
import type { OpenClawConfig } from "./types.js";
const OPEN_DM_POLICY_ALLOW_FROM_RE =
/^(?<policyPath>[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?<allowPath>[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i;
function cloneUnknown<T>(value: T): T {
return structuredClone(value);
}
export function createMergePatch(base: unknown, target: unknown): unknown {
if (!isRecord(base) || !isRecord(target)) {
return cloneUnknown(target);
}
const patch: Record<string, unknown> = {};
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget) {
patch[key] = null;
continue;
}
const targetValue = target[key];
if (!hasBase) {
patch[key] = cloneUnknown(targetValue);
continue;
}
const baseValue = base[key];
if (isRecord(baseValue) && isRecord(targetValue)) {
const childPatch = createMergePatch(baseValue, targetValue);
if (isRecord(childPatch) && Object.keys(childPatch).length === 0) {
continue;
}
patch[key] = childPatch;
continue;
}
if (!isDeepStrictEqual(baseValue, targetValue)) {
patch[key] = cloneUnknown(targetValue);
}
}
return patch;
}
export function projectSourceOntoRuntimeShape(source: unknown, runtime: unknown): unknown {
if (!isRecord(source) || !isRecord(runtime)) {
return cloneUnknown(source);
}
const next: Record<string, unknown> = {};
for (const [key, sourceValue] of Object.entries(source)) {
if (!(key in runtime)) {
continue;
}
next[key] = projectSourceOntoRuntimeShape(sourceValue, runtime[key]);
}
return next;
}
export function resolvePersistCandidateForWrite(params: {
runtimeConfig: unknown;
sourceConfig: unknown;
nextConfig: unknown;
}): unknown {
const patch = createMergePatch(params.runtimeConfig, params.nextConfig);
const projectedSource = projectSourceOntoRuntimeShape(params.sourceConfig, params.runtimeConfig);
return applyMergePatch(projectedSource, patch);
}
export function formatConfigValidationFailure(pathLabel: string, issueMessage: string): string {
const match = issueMessage.match(OPEN_DM_POLICY_ALLOW_FROM_RE);
const policyPath = match?.groups?.policyPath?.trim();
const allowPath = match?.groups?.allowPath?.trim();
if (!policyPath || !allowPath) {
return `Config validation failed: ${pathLabel}: ${issueMessage}`;
}
return [
`Config validation failed: ${pathLabel}`,
"",
`Configuration mismatch: ${policyPath} is "open", but ${allowPath} does not include "*".`,
"",
"Fix with:",
` openclaw config set ${allowPath} '["*"]'`,
"",
"Or switch policy:",
` openclaw config set ${policyPath} "pairing"`,
].join("\n");
}
function isNumericPathSegment(raw: string): boolean {
return /^[0-9]+$/.test(raw);
}
function isWritePlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasOwnObjectKey(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
const WRITE_PRUNED_OBJECT = Symbol("write-pruned-object");
function coerceConfig(value: unknown): OpenClawConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as OpenClawConfig;
}
function unsetPathForWriteAt(
value: unknown,
pathSegments: string[],
depth: number,
): { changed: boolean; value: unknown } {
if (depth >= pathSegments.length) {
return { changed: false, value };
}
const segment = pathSegments[depth];
const isLeaf = depth === pathSegments.length - 1;
if (Array.isArray(value)) {
if (!isNumericPathSegment(segment)) {
return { changed: false, value };
}
const index = Number.parseInt(segment, 10);
if (!Number.isFinite(index) || index < 0 || index >= value.length) {
return { changed: false, value };
}
if (isLeaf) {
const next = value.slice();
next.splice(index, 1);
return { changed: true, value: next };
}
const child = unsetPathForWriteAt(value[index], pathSegments, depth + 1);
if (!child.changed) {
return { changed: false, value };
}
const next = value.slice();
if (child.value === WRITE_PRUNED_OBJECT) {
next.splice(index, 1);
} else {
next[index] = child.value;
}
return { changed: true, value: next };
}
if (
isBlockedObjectKey(segment) ||
!isWritePlainObject(value) ||
!hasOwnObjectKey(value, segment)
) {
return { changed: false, value };
}
if (isLeaf) {
const next: Record<string, unknown> = { ...value };
delete next[segment];
return {
changed: true,
value: Object.keys(next).length === 0 ? WRITE_PRUNED_OBJECT : next,
};
}
const child = unsetPathForWriteAt(value[segment], pathSegments, depth + 1);
if (!child.changed) {
return { changed: false, value };
}
const next: Record<string, unknown> = { ...value };
if (child.value === WRITE_PRUNED_OBJECT) {
delete next[segment];
} else {
next[segment] = child.value;
}
return {
changed: true,
value: Object.keys(next).length === 0 ? WRITE_PRUNED_OBJECT : next,
};
}
export function unsetPathForWrite(
root: OpenClawConfig,
pathSegments: string[],
): { changed: boolean; next: OpenClawConfig } {
if (pathSegments.length === 0) {
return { changed: false, next: root };
}
const result = unsetPathForWriteAt(root, pathSegments, 0);
if (!result.changed) {
return { changed: false, next: root };
}
if (result.value === WRITE_PRUNED_OBJECT) {
return { changed: true, next: {} };
}
if (isWritePlainObject(result.value)) {
return { changed: true, next: coerceConfig(result.value) };
}
return { changed: false, next: root };
}
export function collectChangedPaths(
base: unknown,
target: unknown,
path: string,
output: Set<string>,
): void {
if (Array.isArray(base) && Array.isArray(target)) {
const max = Math.max(base.length, target.length);
for (let index = 0; index < max; index += 1) {
const childPath = path ? `${path}[${index}]` : `[${index}]`;
if (index >= base.length || index >= target.length) {
output.add(childPath);
continue;
}
collectChangedPaths(base[index], target[index], childPath, output);
}
return;
}
if (isRecord(base) && isRecord(target)) {
const keys = new Set([...Object.keys(base), ...Object.keys(target)]);
for (const key of keys) {
const childPath = path ? `${path}.${key}` : key;
const hasBase = key in base;
const hasTarget = key in target;
if (!hasTarget || !hasBase) {
output.add(childPath);
continue;
}
collectChangedPaths(base[key], target[key], childPath, output);
}
return;
}
if (!isDeepStrictEqual(base, target)) {
output.add(path);
}
}
function parentPath(value: string): string {
if (!value) {
return "";
}
if (value.endsWith("]")) {
const index = value.lastIndexOf("[");
return index > 0 ? value.slice(0, index) : "";
}
const index = value.lastIndexOf(".");
return index >= 0 ? value.slice(0, index) : "";
}
function isPathChanged(path: string, changedPaths: Set<string>): boolean {
if (changedPaths.has(path)) {
return true;
}
let current = parentPath(path);
while (current) {
if (changedPaths.has(current)) {
return true;
}
current = parentPath(current);
}
return changedPaths.has("");
}
export function restoreEnvRefsFromMap(
value: unknown,
path: string,
envRefMap: Map<string, string>,
changedPaths: Set<string>,
): unknown {
if (typeof value === "string") {
if (!isPathChanged(path, changedPaths)) {
const original = envRefMap.get(path);
if (original !== undefined) {
return original;
}
}
return value;
}
if (Array.isArray(value)) {
let changed = false;
const next = value.map((item, index) => {
const updated = restoreEnvRefsFromMap(item, `${path}[${index}]`, envRefMap, changedPaths);
if (updated !== item) {
changed = true;
}
return updated;
});
return changed ? next : value;
}
if (isRecord(value)) {
let changed = false;
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
const childPath = path ? `${path}.${key}` : key;
const updated = restoreEnvRefsFromMap(child, childPath, envRefMap, changedPaths);
if (updated !== child) {
changed = true;
}
next[key] = updated;
}
return changed ? next : value;
}
return value;
}
export function resolveWriteEnvSnapshotForPath(params: {
actualConfigPath: string;
expectedConfigPath?: string;
envSnapshotForRestore?: Record<string, string | undefined>;
}): Record<string, string | undefined> | undefined {
if (
params.expectedConfigPath === undefined ||
params.expectedConfigPath === params.actualConfigPath
) {
return params.envSnapshotForRestore;
}
return undefined;
}