mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 17:51:22 +00:00
323 lines
9.2 KiB
TypeScript
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;
|
|
}
|