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 = /^(?[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i; function cloneUnknown(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 = {}; 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 = {}; 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 { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function hasOwnObjectKey(value: Record, 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 = { ...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 = { ...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, ): 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): 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, changedPaths: Set, ): 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 = {}; 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; }): Record | undefined { if ( params.expectedConfigPath === undefined || params.expectedConfigPath === params.actualConfigPath ) { return params.envSnapshotForRestore; } return undefined; }