mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 15:18:58 +00:00
* Allow config includes from approved roots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add changelog for include roots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten include realpath handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: ificator <bcleaver+odspmdb@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2517 lines
83 KiB
TypeScript
2517 lines
83 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import JSON5 from "json5";
|
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js";
|
|
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
|
|
import { applyRuntimeLegacyConfigMigrations } from "../commands/doctor/shared/runtime-compat-api.js";
|
|
import { loadDotEnv } from "../infra/dotenv.js";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
|
import {
|
|
loadShellEnvFallback,
|
|
resolveShellEnvFallbackTimeoutMs,
|
|
shouldDeferShellEnvFallback,
|
|
shouldEnableShellEnvFallback,
|
|
} from "../infra/shell-env.js";
|
|
import {
|
|
collectRelevantDoctorPluginIds,
|
|
listPluginDoctorLegacyConfigRules,
|
|
} from "../plugins/doctor-contract-registry.js";
|
|
import {
|
|
loadInstalledPluginIndexInstallRecordsSync,
|
|
resolveInstalledPluginIndexRecordsStorePath,
|
|
writePersistedInstalledPluginIndexInstallRecordsSync,
|
|
} from "../plugins/installed-plugin-index-records.js";
|
|
import {
|
|
loadPluginMetadataSnapshot,
|
|
type PluginMetadataSnapshot,
|
|
} from "../plugins/plugin-metadata-snapshot.js";
|
|
import { sanitizeTerminalText } from "../terminal/safe-text.js";
|
|
import { isRecord } from "../utils.js";
|
|
import { VERSION } from "../version.js";
|
|
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
|
|
import { maintainConfigBackups } from "./backup-rotation.js";
|
|
import { restoreEnvVarRefs } from "./env-preserve.js";
|
|
import {
|
|
type EnvSubstitutionWarning,
|
|
MissingEnvVarError,
|
|
containsEnvVarReference,
|
|
resolveConfigEnvVars,
|
|
} from "./env-substitution.js";
|
|
import { applyConfigEnvVars } from "./env-vars.js";
|
|
import {
|
|
ConfigIncludeError,
|
|
readConfigIncludeFileWithGuards,
|
|
resolveConfigIncludes,
|
|
} from "./includes.js";
|
|
import {
|
|
appendConfigAuditRecord,
|
|
appendConfigAuditRecordSync,
|
|
createConfigWriteAuditRecordBase,
|
|
finalizeConfigWriteAuditRecord,
|
|
formatConfigOverwriteLogMessage,
|
|
snapshotConfigAuditProcessInfo,
|
|
type ConfigWriteAuditResult,
|
|
} from "./io.audit.js";
|
|
import { throwInvalidConfig } from "./io.invalid-config.js";
|
|
import {
|
|
maybeRecoverSuspiciousConfigRead,
|
|
maybeRecoverSuspiciousConfigReadSync,
|
|
promoteConfigSnapshotToLastKnownGood as promoteConfigSnapshotToLastKnownGoodWithDeps,
|
|
recoverConfigFromLastKnownGood as recoverConfigFromLastKnownGoodWithDeps,
|
|
} from "./io.observe-recovery.js";
|
|
import { persistGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js";
|
|
import {
|
|
collectChangedPaths,
|
|
createMergePatch,
|
|
formatConfigValidationFailure,
|
|
applyUnsetPathsForWrite,
|
|
projectSourceOntoRuntimeShape,
|
|
restoreEnvRefsFromMap,
|
|
resolvePersistCandidateForWrite,
|
|
resolveManagedUnsetPathsForWrite,
|
|
resolveWriteEnvSnapshotForPath,
|
|
} from "./io.write-prepare.js";
|
|
import { findLegacyConfigIssues } from "./legacy.js";
|
|
import {
|
|
asResolvedSourceConfig,
|
|
asRuntimeConfig,
|
|
materializeRuntimeConfig,
|
|
} from "./materialize.js";
|
|
import { applyMergePatch } from "./merge-patch.js";
|
|
import { resolveConfigPath, resolveIncludeRoots, resolveStateDir } from "./paths.js";
|
|
import {
|
|
extractShippedPluginInstallConfigRecords,
|
|
stripShippedPluginInstallConfigRecords,
|
|
} from "./plugin-install-config-migration.js";
|
|
import { applyConfigOverrides } from "./runtime-overrides.js";
|
|
import {
|
|
clearRuntimeConfigSnapshot as clearRuntimeConfigSnapshotState,
|
|
createRuntimeConfigWriteNotification,
|
|
finalizeRuntimeSnapshotWrite,
|
|
getRuntimeConfigSnapshotMetadata as getRuntimeConfigSnapshotMetadataState,
|
|
getRuntimeConfigSnapshot as getRuntimeConfigSnapshotState,
|
|
getRuntimeConfigSourceSnapshot as getRuntimeConfigSourceSnapshotState,
|
|
loadPinnedRuntimeConfig,
|
|
notifyRuntimeConfigWriteListeners,
|
|
registerRuntimeConfigWriteListener,
|
|
resetConfigRuntimeState as resetConfigRuntimeStateState,
|
|
resolveRuntimeConfigCacheKey,
|
|
selectApplicableRuntimeConfig,
|
|
setRuntimeConfigSnapshot as setRuntimeConfigSnapshotState,
|
|
getRuntimeConfigSnapshotRefreshHandler as getRuntimeConfigSnapshotRefreshHandlerState,
|
|
setRuntimeConfigSnapshotRefreshHandler as setRuntimeConfigSnapshotRefreshHandlerState,
|
|
type ConfigWriteAfterWrite,
|
|
type RuntimeConfigWriteNotification,
|
|
} from "./runtime-snapshot.js";
|
|
import { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js";
|
|
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
|
import {
|
|
validateConfigObjectRawWithPlugins,
|
|
validateConfigObjectWithPlugins,
|
|
} from "./validation.js";
|
|
import { shouldWarnOnTouchedVersion } from "./version.js";
|
|
|
|
export {
|
|
clearRuntimeConfigSnapshotState as clearRuntimeConfigSnapshot,
|
|
getRuntimeConfigSnapshotMetadataState as getRuntimeConfigSnapshotMetadata,
|
|
getRuntimeConfigSnapshotState as getRuntimeConfigSnapshot,
|
|
getRuntimeConfigSourceSnapshotState as getRuntimeConfigSourceSnapshot,
|
|
resetConfigRuntimeStateState as resetConfigRuntimeState,
|
|
resolveRuntimeConfigCacheKey,
|
|
selectApplicableRuntimeConfig,
|
|
setRuntimeConfigSnapshotState as setRuntimeConfigSnapshot,
|
|
setRuntimeConfigSnapshotRefreshHandlerState as setRuntimeConfigSnapshotRefreshHandler,
|
|
};
|
|
|
|
// Re-export for backwards compatibility
|
|
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
|
|
export { MissingEnvVarError } from "./env-substitution.js";
|
|
export { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js";
|
|
|
|
type ShippedPluginInstallConfigWriteMigration =
|
|
| {
|
|
migrated: false;
|
|
}
|
|
| {
|
|
migrated: true;
|
|
filePath: string;
|
|
previousFile:
|
|
| {
|
|
existed: false;
|
|
}
|
|
| {
|
|
existed: true;
|
|
raw: string;
|
|
};
|
|
};
|
|
|
|
type ShippedPluginInstallConfigReadMigration = {
|
|
config: unknown;
|
|
persistedRootParsed?: unknown;
|
|
persistedRootRaw?: string;
|
|
};
|
|
|
|
const CONFIG_HEALTH_STATE_FILENAME = "config-health.json";
|
|
const loggedInvalidConfigs = new Set<string>();
|
|
|
|
type ConfigHealthFingerprint = {
|
|
hash: string;
|
|
bytes: number;
|
|
mtimeMs: number | null;
|
|
ctimeMs: number | null;
|
|
dev: string | null;
|
|
ino: string | null;
|
|
mode: number | null;
|
|
nlink: number | null;
|
|
uid: number | null;
|
|
gid: number | null;
|
|
hasMeta: boolean;
|
|
gatewayMode: string | null;
|
|
observedAt: string;
|
|
};
|
|
|
|
type ConfigHealthEntry = {
|
|
lastKnownGood?: ConfigHealthFingerprint;
|
|
lastPromotedGood?: ConfigHealthFingerprint;
|
|
lastObservedSuspiciousSignature?: string | null;
|
|
};
|
|
|
|
type ConfigHealthState = {
|
|
entries?: Record<string, ConfigHealthEntry>;
|
|
};
|
|
|
|
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
|
|
export type ConfigWriteOptions = {
|
|
/**
|
|
* Read-time env snapshot used to validate `${VAR}` restoration decisions.
|
|
* If omitted, write falls back to current process env.
|
|
*/
|
|
envSnapshotForRestore?: Record<string, string | undefined>;
|
|
/**
|
|
* Optional safety check: only use envSnapshotForRestore when writing the
|
|
* same config file path that produced the snapshot.
|
|
*/
|
|
expectedConfigPath?: string;
|
|
/**
|
|
* Paths that must be explicitly removed from the persisted file payload,
|
|
* even if schema/default normalization reintroduces them.
|
|
*/
|
|
unsetPaths?: string[][];
|
|
/**
|
|
* Internal fast path for callers that already hold a fresh config snapshot.
|
|
* Avoids rereading the full config just to prepare an immediate write.
|
|
*/
|
|
baseSnapshot?: ConfigFileSnapshot;
|
|
/**
|
|
* Internal one-shot CLI fast path. When no runtime snapshot is active, skip
|
|
* the post-write runtime snapshot refresh/reload tail entirely.
|
|
*/
|
|
skipRuntimeSnapshotRefresh?: boolean;
|
|
/**
|
|
* Allow intentionally destructive config writes, such as explicit reset flows.
|
|
* Normal writers must keep this false so clobbers are rejected before disk commit.
|
|
*/
|
|
allowDestructiveWrite?: boolean;
|
|
/**
|
|
* Allow an intentional large config size drop while keeping other destructive
|
|
* guards active. Used by repair flows that remove stale or legacy config.
|
|
*/
|
|
allowConfigSizeDrop?: boolean;
|
|
/**
|
|
* Suppress human-readable output logs (overwrite/anomaly messages).
|
|
* Useful when the caller wants machine-readable output only (--json mode).
|
|
*/
|
|
skipOutputLogs?: boolean;
|
|
/**
|
|
* Runtime reload intent for observers that react to committed config writes.
|
|
* Omitted means the observer should use its normal reload plan.
|
|
*/
|
|
afterWrite?: ConfigWriteAfterWrite;
|
|
};
|
|
|
|
export type ReadConfigFileSnapshotForWriteResult = {
|
|
snapshot: ConfigFileSnapshot;
|
|
writeOptions: ConfigWriteOptions;
|
|
};
|
|
|
|
export type ConfigWriteNotification = RuntimeConfigWriteNotification;
|
|
export type ConfigSnapshotReadMeasure = <T>(name: string, run: () => T | Promise<T>) => Promise<T>;
|
|
|
|
export class ConfigRuntimeRefreshError extends Error {
|
|
constructor(message: string, options?: { cause?: unknown }) {
|
|
super(message, options);
|
|
this.name = "ConfigRuntimeRefreshError";
|
|
}
|
|
}
|
|
|
|
function hashConfigRaw(raw: string | null): string {
|
|
return crypto
|
|
.createHash("sha256")
|
|
.update(raw ?? "")
|
|
.digest("hex");
|
|
}
|
|
|
|
async function tightenStateDirPermissionsIfNeeded(params: {
|
|
configPath: string;
|
|
env: NodeJS.ProcessEnv;
|
|
homedir: () => string;
|
|
fsModule: typeof fs;
|
|
}): Promise<void> {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
const stateDir = resolveStateDir(params.env, params.homedir);
|
|
const configDir = path.dirname(params.configPath);
|
|
if (path.resolve(configDir) !== path.resolve(stateDir)) {
|
|
return;
|
|
}
|
|
try {
|
|
const stat = await params.fsModule.promises.stat(configDir);
|
|
const mode = stat.mode & 0o777;
|
|
if ((mode & 0o077) === 0) {
|
|
return;
|
|
}
|
|
await params.fsModule.promises.chmod(configDir, 0o700);
|
|
} catch {
|
|
// Best-effort hardening only; callers still need the config write to proceed.
|
|
}
|
|
}
|
|
|
|
export function resolveConfigSnapshotHash(snapshot: {
|
|
hash?: string;
|
|
raw?: string | null;
|
|
}): string | null {
|
|
if (typeof snapshot.hash === "string") {
|
|
const trimmed = snapshot.hash.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
}
|
|
if (typeof snapshot.raw !== "string") {
|
|
return null;
|
|
}
|
|
return hashConfigRaw(snapshot.raw);
|
|
}
|
|
|
|
function coerceConfig(value: unknown): OpenClawConfig {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
return value as OpenClawConfig;
|
|
}
|
|
|
|
function hasConfigMeta(value: unknown): boolean {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
const meta = value.meta;
|
|
return isRecord(meta);
|
|
}
|
|
|
|
function resolveGatewayMode(value: unknown): string | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
const gateway = value.gateway;
|
|
if (!isRecord(gateway) || typeof gateway.mode !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = gateway.mode.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function collectEnvRefPaths(value: unknown, path: string, output: Map<string, string>): void {
|
|
if (typeof value === "string") {
|
|
if (containsEnvVarReference(value)) {
|
|
output.set(path, value);
|
|
}
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item, index) => {
|
|
collectEnvRefPaths(item, `${path}[${index}]`, output);
|
|
});
|
|
return;
|
|
}
|
|
if (isRecord(value)) {
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const childPath = path ? `${path}.${key}` : key;
|
|
collectEnvRefPaths(child, childPath, output);
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => string): string {
|
|
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_HEALTH_STATE_FILENAME);
|
|
}
|
|
|
|
function normalizeStatNumber(value: number | null | undefined): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
function normalizeStatId(value: number | bigint | null | undefined): string | null {
|
|
if (typeof value === "bigint") {
|
|
return value.toString();
|
|
}
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return String(value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveConfigStatMetadata(
|
|
stat: fs.Stats | null,
|
|
): Pick<ConfigHealthFingerprint, "dev" | "ino" | "mode" | "nlink" | "uid" | "gid"> {
|
|
return {
|
|
dev: normalizeStatId(stat?.dev ?? null),
|
|
ino: normalizeStatId(stat?.ino ?? null),
|
|
mode: normalizeStatNumber(stat ? stat.mode & 0o777 : null),
|
|
nlink: normalizeStatNumber(stat?.nlink ?? null),
|
|
uid: normalizeStatNumber(stat?.uid ?? null),
|
|
gid: normalizeStatNumber(stat?.gid ?? null),
|
|
};
|
|
}
|
|
|
|
function resolveConfigWriteSuspiciousReasons(params: {
|
|
existsBefore: boolean;
|
|
previousBytes: number | null;
|
|
nextBytes: number | null;
|
|
hasMetaBefore: boolean;
|
|
gatewayModeBefore: string | null;
|
|
gatewayModeAfter: string | null;
|
|
}): string[] {
|
|
const reasons: string[] = [];
|
|
if (!params.existsBefore) {
|
|
return reasons;
|
|
}
|
|
if (
|
|
typeof params.previousBytes === "number" &&
|
|
typeof params.nextBytes === "number" &&
|
|
params.previousBytes >= 512 &&
|
|
params.nextBytes < Math.floor(params.previousBytes * 0.5)
|
|
) {
|
|
reasons.push(`size-drop:${params.previousBytes}->${params.nextBytes}`);
|
|
}
|
|
if (!params.hasMetaBefore) {
|
|
reasons.push("missing-meta-before-write");
|
|
}
|
|
if (params.gatewayModeBefore && !params.gatewayModeAfter) {
|
|
reasons.push("gateway-mode-removed");
|
|
}
|
|
return reasons;
|
|
}
|
|
|
|
function resolveConfigWriteBlockingReasons(
|
|
suspicious: string[],
|
|
options: Pick<ConfigWriteOptions, "allowConfigSizeDrop"> = {},
|
|
): string[] {
|
|
return suspicious.filter(
|
|
(reason) =>
|
|
(reason.startsWith("size-drop:") && options.allowConfigSizeDrop !== true) ||
|
|
reason === "gateway-mode-removed",
|
|
);
|
|
}
|
|
|
|
async function readConfigHealthState(deps: Required<ConfigIoDeps>): Promise<ConfigHealthState> {
|
|
try {
|
|
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
|
const raw = await deps.fs.promises.readFile(healthPath, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
return isRecord(parsed) ? (parsed as ConfigHealthState) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function readConfigHealthStateSync(deps: Required<ConfigIoDeps>): ConfigHealthState {
|
|
try {
|
|
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
|
const raw = deps.fs.readFileSync(healthPath, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
return isRecord(parsed) ? (parsed as ConfigHealthState) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function writeConfigHealthState(
|
|
deps: Required<ConfigIoDeps>,
|
|
state: ConfigHealthState,
|
|
): Promise<void> {
|
|
try {
|
|
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
|
await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 });
|
|
await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
function writeConfigHealthStateSync(deps: Required<ConfigIoDeps>, state: ConfigHealthState): void {
|
|
try {
|
|
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
|
|
deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 });
|
|
deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
function getConfigHealthEntry(state: ConfigHealthState, configPath: string): ConfigHealthEntry {
|
|
const entries = state.entries;
|
|
if (!entries || !isRecord(entries)) {
|
|
return {};
|
|
}
|
|
const entry = entries[configPath];
|
|
return entry && isRecord(entry) ? entry : {};
|
|
}
|
|
|
|
function setConfigHealthEntry(
|
|
state: ConfigHealthState,
|
|
configPath: string,
|
|
entry: ConfigHealthEntry,
|
|
): ConfigHealthState {
|
|
return {
|
|
...state,
|
|
entries: {
|
|
...state.entries,
|
|
[configPath]: entry,
|
|
},
|
|
};
|
|
}
|
|
|
|
function isUpdateChannelOnlyRoot(value: unknown): boolean {
|
|
if (!isRecord(value)) {
|
|
return false;
|
|
}
|
|
const keys = Object.keys(value);
|
|
if (keys.length !== 1 || keys[0] !== "update") {
|
|
return false;
|
|
}
|
|
const update = value.update;
|
|
if (!isRecord(update)) {
|
|
return false;
|
|
}
|
|
const updateKeys = Object.keys(update);
|
|
return updateKeys.length === 1 && typeof update.channel === "string";
|
|
}
|
|
|
|
function resolveConfigObserveSuspiciousReasons(params: {
|
|
bytes: number;
|
|
hasMeta: boolean;
|
|
gatewayMode: string | null;
|
|
parsed: unknown;
|
|
lastKnownGood?: ConfigHealthFingerprint;
|
|
}): string[] {
|
|
const reasons: string[] = [];
|
|
const baseline = params.lastKnownGood;
|
|
if (!baseline) {
|
|
return reasons;
|
|
}
|
|
if (baseline.bytes >= 512 && params.bytes < Math.floor(baseline.bytes * 0.5)) {
|
|
reasons.push(`size-drop-vs-last-good:${baseline.bytes}->${params.bytes}`);
|
|
}
|
|
if (baseline.hasMeta && !params.hasMeta) {
|
|
reasons.push("missing-meta-vs-last-good");
|
|
}
|
|
if (baseline.gatewayMode && !params.gatewayMode) {
|
|
reasons.push("gateway-mode-missing-vs-last-good");
|
|
}
|
|
if (baseline.gatewayMode && isUpdateChannelOnlyRoot(params.parsed)) {
|
|
reasons.push("update-channel-only-root");
|
|
}
|
|
return reasons;
|
|
}
|
|
|
|
async function readConfigFingerprintForPath(
|
|
deps: Required<ConfigIoDeps>,
|
|
targetPath: string,
|
|
): Promise<ConfigHealthFingerprint | null> {
|
|
try {
|
|
const raw = await deps.fs.promises.readFile(targetPath, "utf-8");
|
|
const stat = await deps.fs.promises.stat(targetPath).catch(() => null);
|
|
const parsedRes = parseConfigJson5(raw, deps.json5);
|
|
const parsed = parsedRes.ok ? parsedRes.parsed : {};
|
|
return {
|
|
hash: hashConfigRaw(raw),
|
|
bytes: Buffer.byteLength(raw, "utf-8"),
|
|
mtimeMs: stat?.mtimeMs ?? null,
|
|
ctimeMs: stat?.ctimeMs ?? null,
|
|
...resolveConfigStatMetadata(stat),
|
|
hasMeta: hasConfigMeta(parsed),
|
|
gatewayMode: resolveGatewayMode(parsed),
|
|
observedAt: new Date().toISOString(),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readConfigFingerprintForPathSync(
|
|
deps: Required<ConfigIoDeps>,
|
|
targetPath: string,
|
|
): ConfigHealthFingerprint | null {
|
|
try {
|
|
const raw = deps.fs.readFileSync(targetPath, "utf-8");
|
|
const stat = deps.fs.statSync(targetPath, { throwIfNoEntry: false }) ?? null;
|
|
const parsedRes = parseConfigJson5(raw, deps.json5);
|
|
const parsed = parsedRes.ok ? parsedRes.parsed : {};
|
|
return {
|
|
hash: hashConfigRaw(raw),
|
|
bytes: Buffer.byteLength(raw, "utf-8"),
|
|
mtimeMs: stat?.mtimeMs ?? null,
|
|
ctimeMs: stat?.ctimeMs ?? null,
|
|
...resolveConfigStatMetadata(stat),
|
|
hasMeta: hasConfigMeta(parsed),
|
|
gatewayMode: resolveGatewayMode(parsed),
|
|
observedAt: new Date().toISOString(),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function formatConfigArtifactTimestamp(ts: string): string {
|
|
return ts.replaceAll(":", "-").replaceAll(".", "-");
|
|
}
|
|
|
|
async function persistClobberedConfigSnapshot(params: {
|
|
deps: Required<ConfigIoDeps>;
|
|
configPath: string;
|
|
raw: string;
|
|
observedAt: string;
|
|
}): Promise<string | null> {
|
|
const targetPath = `${params.configPath}.clobbered.${formatConfigArtifactTimestamp(params.observedAt)}`;
|
|
try {
|
|
await params.deps.fs.promises.writeFile(targetPath, params.raw, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
flag: "wx",
|
|
});
|
|
return targetPath;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function persistClobberedConfigSnapshotSync(params: {
|
|
deps: Required<ConfigIoDeps>;
|
|
configPath: string;
|
|
raw: string;
|
|
observedAt: string;
|
|
}): string | null {
|
|
const targetPath = `${params.configPath}.clobbered.${formatConfigArtifactTimestamp(params.observedAt)}`;
|
|
try {
|
|
params.deps.fs.writeFileSync(targetPath, params.raw, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
flag: "wx",
|
|
});
|
|
return targetPath;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function sameFingerprint(
|
|
left: ConfigHealthFingerprint | undefined,
|
|
right: ConfigHealthFingerprint,
|
|
): boolean {
|
|
if (!left) {
|
|
return false;
|
|
}
|
|
return (
|
|
left.hash === right.hash &&
|
|
left.bytes === right.bytes &&
|
|
left.mtimeMs === right.mtimeMs &&
|
|
left.ctimeMs === right.ctimeMs &&
|
|
left.dev === right.dev &&
|
|
left.ino === right.ino &&
|
|
left.mode === right.mode &&
|
|
left.nlink === right.nlink &&
|
|
left.uid === right.uid &&
|
|
left.gid === right.gid &&
|
|
left.hasMeta === right.hasMeta &&
|
|
left.gatewayMode === right.gatewayMode
|
|
);
|
|
}
|
|
|
|
async function observeConfigSnapshot(
|
|
deps: Required<ConfigIoDeps>,
|
|
snapshot: ConfigFileSnapshot,
|
|
): Promise<void> {
|
|
if (!snapshot.exists || typeof snapshot.raw !== "string") {
|
|
return;
|
|
}
|
|
|
|
const stat = await deps.fs.promises.stat(snapshot.path).catch(() => null);
|
|
const now = new Date().toISOString();
|
|
const current: ConfigHealthFingerprint = {
|
|
hash: resolveConfigSnapshotHash(snapshot) ?? hashConfigRaw(snapshot.raw),
|
|
bytes: Buffer.byteLength(snapshot.raw, "utf-8"),
|
|
mtimeMs: stat?.mtimeMs ?? null,
|
|
ctimeMs: stat?.ctimeMs ?? null,
|
|
...resolveConfigStatMetadata(stat),
|
|
hasMeta: hasConfigMeta(snapshot.parsed),
|
|
gatewayMode: resolveGatewayMode(snapshot.resolved),
|
|
observedAt: now,
|
|
};
|
|
|
|
let healthState = await readConfigHealthState(deps);
|
|
const entry = getConfigHealthEntry(healthState, snapshot.path);
|
|
const backupBaseline =
|
|
entry.lastKnownGood ??
|
|
(await readConfigFingerprintForPath(deps, `${snapshot.path}.bak`)) ??
|
|
undefined;
|
|
const suspicious = resolveConfigObserveSuspiciousReasons({
|
|
bytes: current.bytes,
|
|
hasMeta: current.hasMeta,
|
|
gatewayMode: current.gatewayMode,
|
|
parsed: snapshot.parsed,
|
|
lastKnownGood: backupBaseline,
|
|
});
|
|
|
|
if (suspicious.length === 0) {
|
|
if (snapshot.valid) {
|
|
const nextEntry: ConfigHealthEntry = {
|
|
...entry,
|
|
lastKnownGood: current,
|
|
lastObservedSuspiciousSignature: null,
|
|
};
|
|
if (
|
|
!sameFingerprint(entry.lastKnownGood, current) ||
|
|
entry.lastObservedSuspiciousSignature !== null
|
|
) {
|
|
healthState = setConfigHealthEntry(healthState, snapshot.path, nextEntry);
|
|
await writeConfigHealthState(deps, healthState);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const suspiciousSignature = `${current.hash}:${suspicious.join(",")}`;
|
|
if (entry.lastObservedSuspiciousSignature === suspiciousSignature) {
|
|
return;
|
|
}
|
|
|
|
const backup =
|
|
(backupBaseline?.hash ? backupBaseline : null) ??
|
|
(await readConfigFingerprintForPath(deps, `${snapshot.path}.bak`));
|
|
const clobberedPath = await persistClobberedConfigSnapshot({
|
|
deps,
|
|
configPath: snapshot.path,
|
|
raw: snapshot.raw,
|
|
observedAt: now,
|
|
});
|
|
|
|
deps.logger.warn(`Config observe anomaly: ${snapshot.path} (${suspicious.join(", ")})`);
|
|
await appendConfigAuditRecord({
|
|
fs: deps.fs,
|
|
env: deps.env,
|
|
homedir: deps.homedir,
|
|
record: {
|
|
ts: now,
|
|
source: "config-io",
|
|
event: "config.observe",
|
|
phase: "read",
|
|
configPath: snapshot.path,
|
|
...snapshotConfigAuditProcessInfo(),
|
|
exists: true,
|
|
valid: snapshot.valid,
|
|
hash: current.hash,
|
|
bytes: current.bytes,
|
|
mtimeMs: current.mtimeMs,
|
|
ctimeMs: current.ctimeMs,
|
|
dev: current.dev,
|
|
ino: current.ino,
|
|
mode: current.mode,
|
|
nlink: current.nlink,
|
|
uid: current.uid,
|
|
gid: current.gid,
|
|
hasMeta: current.hasMeta,
|
|
gatewayMode: current.gatewayMode,
|
|
suspicious,
|
|
lastKnownGoodHash: entry.lastKnownGood?.hash ?? null,
|
|
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
|
|
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
|
|
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
|
|
lastKnownGoodDev: entry.lastKnownGood?.dev ?? null,
|
|
lastKnownGoodIno: entry.lastKnownGood?.ino ?? null,
|
|
lastKnownGoodMode: entry.lastKnownGood?.mode ?? null,
|
|
lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null,
|
|
lastKnownGoodUid: entry.lastKnownGood?.uid ?? null,
|
|
lastKnownGoodGid: entry.lastKnownGood?.gid ?? null,
|
|
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
|
|
backupHash: backup?.hash ?? null,
|
|
backupBytes: backup?.bytes ?? null,
|
|
backupMtimeMs: backup?.mtimeMs ?? null,
|
|
backupCtimeMs: backup?.ctimeMs ?? null,
|
|
backupDev: backup?.dev ?? null,
|
|
backupIno: backup?.ino ?? null,
|
|
backupMode: backup?.mode ?? null,
|
|
backupNlink: backup?.nlink ?? null,
|
|
backupUid: backup?.uid ?? null,
|
|
backupGid: backup?.gid ?? null,
|
|
backupGatewayMode: backup?.gatewayMode ?? null,
|
|
clobberedPath,
|
|
restoredFromBackup: false,
|
|
restoredBackupPath: null,
|
|
restoreErrorCode: null,
|
|
restoreErrorMessage: null,
|
|
},
|
|
});
|
|
|
|
healthState = setConfigHealthEntry(healthState, snapshot.path, {
|
|
...entry,
|
|
lastObservedSuspiciousSignature: suspiciousSignature,
|
|
});
|
|
await writeConfigHealthState(deps, healthState);
|
|
}
|
|
|
|
function observeConfigSnapshotSync(
|
|
deps: Required<ConfigIoDeps>,
|
|
snapshot: ConfigFileSnapshot,
|
|
): void {
|
|
if (!snapshot.exists || typeof snapshot.raw !== "string") {
|
|
return;
|
|
}
|
|
|
|
const stat = deps.fs.statSync(snapshot.path, { throwIfNoEntry: false }) ?? null;
|
|
const now = new Date().toISOString();
|
|
const current: ConfigHealthFingerprint = {
|
|
hash: resolveConfigSnapshotHash(snapshot) ?? hashConfigRaw(snapshot.raw),
|
|
bytes: Buffer.byteLength(snapshot.raw, "utf-8"),
|
|
mtimeMs: stat?.mtimeMs ?? null,
|
|
ctimeMs: stat?.ctimeMs ?? null,
|
|
...resolveConfigStatMetadata(stat),
|
|
hasMeta: hasConfigMeta(snapshot.parsed),
|
|
gatewayMode: resolveGatewayMode(snapshot.resolved),
|
|
observedAt: now,
|
|
};
|
|
|
|
let healthState = readConfigHealthStateSync(deps);
|
|
const entry = getConfigHealthEntry(healthState, snapshot.path);
|
|
const backupBaseline =
|
|
entry.lastKnownGood ??
|
|
readConfigFingerprintForPathSync(deps, `${snapshot.path}.bak`) ??
|
|
undefined;
|
|
const suspicious = resolveConfigObserveSuspiciousReasons({
|
|
bytes: current.bytes,
|
|
hasMeta: current.hasMeta,
|
|
gatewayMode: current.gatewayMode,
|
|
parsed: snapshot.parsed,
|
|
lastKnownGood: backupBaseline,
|
|
});
|
|
|
|
if (suspicious.length === 0) {
|
|
if (snapshot.valid) {
|
|
const nextEntry: ConfigHealthEntry = {
|
|
...entry,
|
|
lastKnownGood: current,
|
|
lastObservedSuspiciousSignature: null,
|
|
};
|
|
if (
|
|
!sameFingerprint(entry.lastKnownGood, current) ||
|
|
entry.lastObservedSuspiciousSignature !== null
|
|
) {
|
|
healthState = setConfigHealthEntry(healthState, snapshot.path, nextEntry);
|
|
writeConfigHealthStateSync(deps, healthState);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const suspiciousSignature = `${current.hash}:${suspicious.join(",")}`;
|
|
if (entry.lastObservedSuspiciousSignature === suspiciousSignature) {
|
|
return;
|
|
}
|
|
|
|
const backup =
|
|
(backupBaseline?.hash ? backupBaseline : null) ??
|
|
readConfigFingerprintForPathSync(deps, `${snapshot.path}.bak`);
|
|
const clobberedPath = persistClobberedConfigSnapshotSync({
|
|
deps,
|
|
configPath: snapshot.path,
|
|
raw: snapshot.raw,
|
|
observedAt: now,
|
|
});
|
|
|
|
deps.logger.warn(`Config observe anomaly: ${snapshot.path} (${suspicious.join(", ")})`);
|
|
appendConfigAuditRecordSync({
|
|
fs: deps.fs,
|
|
env: deps.env,
|
|
homedir: deps.homedir,
|
|
record: {
|
|
ts: now,
|
|
source: "config-io",
|
|
event: "config.observe",
|
|
phase: "read",
|
|
configPath: snapshot.path,
|
|
...snapshotConfigAuditProcessInfo(),
|
|
exists: true,
|
|
valid: snapshot.valid,
|
|
hash: current.hash,
|
|
bytes: current.bytes,
|
|
mtimeMs: current.mtimeMs,
|
|
ctimeMs: current.ctimeMs,
|
|
dev: current.dev,
|
|
ino: current.ino,
|
|
mode: current.mode,
|
|
nlink: current.nlink,
|
|
uid: current.uid,
|
|
gid: current.gid,
|
|
hasMeta: current.hasMeta,
|
|
gatewayMode: current.gatewayMode,
|
|
suspicious,
|
|
lastKnownGoodHash: entry.lastKnownGood?.hash ?? null,
|
|
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
|
|
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
|
|
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
|
|
lastKnownGoodDev: entry.lastKnownGood?.dev ?? null,
|
|
lastKnownGoodIno: entry.lastKnownGood?.ino ?? null,
|
|
lastKnownGoodMode: entry.lastKnownGood?.mode ?? null,
|
|
lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null,
|
|
lastKnownGoodUid: entry.lastKnownGood?.uid ?? null,
|
|
lastKnownGoodGid: entry.lastKnownGood?.gid ?? null,
|
|
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
|
|
backupHash: backup?.hash ?? null,
|
|
backupBytes: backup?.bytes ?? null,
|
|
backupMtimeMs: backup?.mtimeMs ?? null,
|
|
backupCtimeMs: backup?.ctimeMs ?? null,
|
|
backupDev: backup?.dev ?? null,
|
|
backupIno: backup?.ino ?? null,
|
|
backupMode: backup?.mode ?? null,
|
|
backupNlink: backup?.nlink ?? null,
|
|
backupUid: backup?.uid ?? null,
|
|
backupGid: backup?.gid ?? null,
|
|
backupGatewayMode: backup?.gatewayMode ?? null,
|
|
clobberedPath,
|
|
restoredFromBackup: false,
|
|
restoredBackupPath: null,
|
|
restoreErrorCode: null,
|
|
restoreErrorMessage: null,
|
|
},
|
|
});
|
|
|
|
healthState = setConfigHealthEntry(healthState, snapshot.path, {
|
|
...entry,
|
|
lastObservedSuspiciousSignature: suspiciousSignature,
|
|
});
|
|
writeConfigHealthStateSync(deps, healthState);
|
|
}
|
|
|
|
export type ConfigIoDeps = {
|
|
fs?: typeof fs;
|
|
json5?: typeof JSON5;
|
|
env?: NodeJS.ProcessEnv;
|
|
homedir?: () => string;
|
|
configPath?: string;
|
|
logger?: Pick<typeof console, "error" | "warn">;
|
|
measure?: ConfigSnapshotReadMeasure;
|
|
};
|
|
|
|
function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">): void {
|
|
if (!raw || typeof raw !== "object") {
|
|
return;
|
|
}
|
|
const gateway = (raw as Record<string, unknown>).gateway;
|
|
if (!gateway || typeof gateway !== "object") {
|
|
return;
|
|
}
|
|
if ("token" in (gateway as Record<string, unknown>)) {
|
|
logger.warn(
|
|
'Config uses "gateway.token". This key is ignored; use "gateway.auth.token" instead.',
|
|
);
|
|
}
|
|
}
|
|
|
|
function stampConfigVersion(cfg: OpenClawConfig): OpenClawConfig {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
...cfg,
|
|
meta: {
|
|
...cfg.meta,
|
|
lastTouchedVersion: VERSION,
|
|
lastTouchedAt: now,
|
|
},
|
|
};
|
|
}
|
|
|
|
function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick<typeof console, "warn">): void {
|
|
const touched = cfg.meta?.lastTouchedVersion;
|
|
if (!touched) {
|
|
return;
|
|
}
|
|
if (shouldWarnOnTouchedVersion(VERSION, touched)) {
|
|
logger.warn(
|
|
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
|
|
if (deps.configPath) {
|
|
return deps.configPath;
|
|
}
|
|
return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
|
|
}
|
|
|
|
function normalizeDeps(overrides: ConfigIoDeps = {}): Required<ConfigIoDeps> {
|
|
return {
|
|
fs: overrides.fs ?? fs,
|
|
json5: overrides.json5 ?? JSON5,
|
|
env: overrides.env ?? process.env,
|
|
homedir:
|
|
overrides.homedir ?? (() => resolveRequiredHomeDir(overrides.env ?? process.env, os.homedir)),
|
|
configPath: overrides.configPath ?? "",
|
|
logger: overrides.logger ?? console,
|
|
measure: overrides.measure ?? (async (_name, run) => await run()),
|
|
};
|
|
}
|
|
|
|
function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void {
|
|
// Only hydrate dotenv for the real process env. Callers using injected env
|
|
// objects (tests/diagnostics) should stay isolated.
|
|
if (env !== process.env) {
|
|
return;
|
|
}
|
|
loadDotEnv({ quiet: true });
|
|
}
|
|
|
|
export function parseConfigJson5(
|
|
raw: string,
|
|
json5: { parse: (value: string) => unknown } = JSON5,
|
|
): ParseConfigJson5Result {
|
|
try {
|
|
return { ok: true, parsed: json5.parse(raw) };
|
|
} catch (err) {
|
|
return { ok: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
function findJsonRootSuffix(
|
|
raw: string,
|
|
json5: { parse: (value: string) => unknown } = JSON5,
|
|
): { raw: string; parsed: unknown } | null {
|
|
if (/^\s*(?:\{|\[)/.test(raw)) {
|
|
return null;
|
|
}
|
|
let offset = 0;
|
|
while (offset < raw.length) {
|
|
const nextNewline = raw.indexOf("\n", offset);
|
|
const lineEnd = nextNewline === -1 ? raw.length : nextNewline + 1;
|
|
const line = raw.slice(offset, lineEnd);
|
|
if (/^\s*(?:\{|\[)/.test(line)) {
|
|
const candidate = raw.slice(offset);
|
|
const parsed = parseConfigJson5(candidate, json5);
|
|
return parsed.ok ? { raw: candidate, parsed: parsed.parsed } : null;
|
|
}
|
|
offset = lineEnd;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function persistPrefixedConfigRecovery(params: {
|
|
deps: Required<ConfigIoDeps>;
|
|
configPath: string;
|
|
originalRaw: string;
|
|
recoveredRaw: string;
|
|
}): Promise<void> {
|
|
const observedAt = new Date().toISOString();
|
|
const clobberedPath = await persistClobberedConfigSnapshot({
|
|
deps: params.deps,
|
|
configPath: params.configPath,
|
|
raw: params.originalRaw,
|
|
observedAt,
|
|
});
|
|
await params.deps.fs.promises.writeFile(params.configPath, params.recoveredRaw, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
await params.deps.fs.promises.chmod?.(params.configPath, 0o600).catch(() => {});
|
|
params.deps.logger.warn(
|
|
`Config auto-stripped non-JSON prefix: ${params.configPath}` +
|
|
(clobberedPath ? ` (original saved as ${clobberedPath})` : ""),
|
|
);
|
|
}
|
|
|
|
async function recoverConfigFromJsonRootSuffixWithDeps(params: {
|
|
deps: Required<ConfigIoDeps>;
|
|
configPath: string;
|
|
snapshot: ConfigFileSnapshot;
|
|
}): Promise<boolean> {
|
|
if (!params.snapshot.exists || params.snapshot.valid || typeof params.snapshot.raw !== "string") {
|
|
return false;
|
|
}
|
|
const suffixRecovery = findJsonRootSuffix(params.snapshot.raw, params.deps.json5);
|
|
if (!suffixRecovery) {
|
|
return false;
|
|
}
|
|
|
|
let resolved: unknown;
|
|
try {
|
|
resolved = resolveConfigIncludesForRead(suffixRecovery.parsed, params.configPath, params.deps);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const readResolution = resolveConfigForRead(resolved, params.deps.env);
|
|
const legacyResolution = resolveLegacyConfigForRead(
|
|
readResolution.resolvedConfigRaw,
|
|
suffixRecovery.parsed,
|
|
);
|
|
const validated = validateConfigObjectWithPlugins(
|
|
stripShippedPluginInstallConfigRecords(legacyResolution.effectiveConfigRaw),
|
|
{
|
|
env: params.deps.env,
|
|
},
|
|
);
|
|
if (!validated.ok) {
|
|
return false;
|
|
}
|
|
|
|
await persistPrefixedConfigRecovery({
|
|
deps: params.deps,
|
|
configPath: params.configPath,
|
|
originalRaw: params.snapshot.raw,
|
|
recoveredRaw: suffixRecovery.raw,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
type ConfigReadResolution = {
|
|
resolvedConfigRaw: unknown;
|
|
envSnapshotForRestore: Record<string, string | undefined>;
|
|
envWarnings: EnvSubstitutionWarning[];
|
|
};
|
|
|
|
type LegacyMigrationResolution = {
|
|
effectiveConfigRaw: unknown;
|
|
sourceLegacyIssues: LegacyConfigIssue[];
|
|
};
|
|
|
|
function resolveConfigIncludesForRead(
|
|
parsed: unknown,
|
|
configPath: string,
|
|
deps: Required<ConfigIoDeps>,
|
|
): unknown {
|
|
return resolveConfigIncludes(
|
|
parsed,
|
|
configPath,
|
|
{
|
|
readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
|
|
readFileWithGuards: ({ includePath, resolvedPath, rootRealDir }) =>
|
|
readConfigIncludeFileWithGuards({
|
|
includePath,
|
|
resolvedPath,
|
|
rootRealDir,
|
|
ioFs: deps.fs,
|
|
}),
|
|
parseJson: (raw) => deps.json5.parse(raw),
|
|
},
|
|
{ allowedRoots: resolveIncludeRoots(deps.env, deps.homedir) },
|
|
);
|
|
}
|
|
|
|
function resolveConfigForRead(
|
|
resolvedIncludes: unknown,
|
|
env: NodeJS.ProcessEnv,
|
|
): ConfigReadResolution {
|
|
// Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars.
|
|
if (resolvedIncludes && typeof resolvedIncludes === "object" && "env" in resolvedIncludes) {
|
|
applyConfigEnvVars(resolvedIncludes as OpenClawConfig, env);
|
|
}
|
|
|
|
// Collect missing env var references as warnings instead of throwing,
|
|
// so non-critical config sections with unset vars don't crash the gateway.
|
|
const envWarnings: EnvSubstitutionWarning[] = [];
|
|
return {
|
|
resolvedConfigRaw: resolveConfigEnvVars(resolvedIncludes, env, {
|
|
onMissing: (w) => envWarnings.push(w),
|
|
}),
|
|
// Capture env snapshot after substitution for write-time ${VAR} restoration.
|
|
envSnapshotForRestore: { ...env } as Record<string, string | undefined>,
|
|
envWarnings,
|
|
};
|
|
}
|
|
|
|
function resolveLegacyConfigForRead(
|
|
resolvedConfigRaw: unknown,
|
|
sourceRaw: unknown,
|
|
): LegacyMigrationResolution {
|
|
const pluginIds = collectRelevantDoctorPluginIds(resolvedConfigRaw);
|
|
const sourceLegacyIssues = findLegacyConfigIssues(
|
|
resolvedConfigRaw,
|
|
sourceRaw,
|
|
listPluginDoctorLegacyConfigRules({ pluginIds }),
|
|
);
|
|
if (!resolvedConfigRaw || typeof resolvedConfigRaw !== "object") {
|
|
return {
|
|
effectiveConfigRaw: resolvedConfigRaw,
|
|
sourceLegacyIssues,
|
|
};
|
|
}
|
|
const compat = applyRuntimeLegacyConfigMigrations(resolvedConfigRaw);
|
|
return {
|
|
effectiveConfigRaw: compat.next ?? resolvedConfigRaw,
|
|
sourceLegacyIssues,
|
|
};
|
|
}
|
|
|
|
type ReadConfigFileSnapshotInternalResult = {
|
|
snapshot: ConfigFileSnapshot;
|
|
envSnapshotForRestore?: Record<string, string | undefined>;
|
|
pluginMetadataSnapshot?: PluginMetadataSnapshot;
|
|
};
|
|
|
|
export type ReadConfigFileSnapshotWithPluginMetadataResult = {
|
|
snapshot: ConfigFileSnapshot;
|
|
pluginMetadataSnapshot?: PluginMetadataSnapshot;
|
|
};
|
|
|
|
function createConfigFileSnapshot(params: {
|
|
path: string;
|
|
exists: boolean;
|
|
raw: string | null;
|
|
parsed: unknown;
|
|
sourceConfig: OpenClawConfig;
|
|
valid: boolean;
|
|
runtimeConfig: OpenClawConfig;
|
|
hash?: string;
|
|
issues: ConfigFileSnapshot["issues"];
|
|
warnings: ConfigFileSnapshot["warnings"];
|
|
legacyIssues: LegacyConfigIssue[];
|
|
}): ConfigFileSnapshot {
|
|
const sourceConfig = asResolvedSourceConfig(params.sourceConfig);
|
|
const runtimeConfig = asRuntimeConfig(params.runtimeConfig);
|
|
return {
|
|
path: params.path,
|
|
exists: params.exists,
|
|
raw: params.raw,
|
|
parsed: params.parsed,
|
|
sourceConfig,
|
|
resolved: sourceConfig,
|
|
valid: params.valid,
|
|
runtimeConfig,
|
|
config: runtimeConfig,
|
|
hash: params.hash,
|
|
issues: params.issues,
|
|
warnings: params.warnings,
|
|
legacyIssues: params.legacyIssues,
|
|
};
|
|
}
|
|
|
|
async function finalizeReadConfigSnapshotInternalResult(
|
|
deps: Required<ConfigIoDeps>,
|
|
result: ReadConfigFileSnapshotInternalResult,
|
|
): Promise<ReadConfigFileSnapshotInternalResult> {
|
|
await observeConfigSnapshot(deps, result.snapshot);
|
|
return result;
|
|
}
|
|
|
|
export function createConfigIO(
|
|
overrides: ConfigIoDeps & { pluginValidation?: "full" | "skip" } = {},
|
|
) {
|
|
const deps = normalizeDeps(overrides);
|
|
const configPath = resolveConfigPathForDeps(deps);
|
|
|
|
function observeLoadConfigSnapshot(snapshot: ConfigFileSnapshot): ConfigFileSnapshot {
|
|
observeConfigSnapshotSync(deps, snapshot);
|
|
return snapshot;
|
|
}
|
|
|
|
function finalizeLoadedRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
|
|
const duplicates = findDuplicateAgentDirs(cfg, {
|
|
env: deps.env,
|
|
homedir: deps.homedir,
|
|
});
|
|
if (duplicates.length > 0) {
|
|
throw new DuplicateAgentDirError(duplicates);
|
|
}
|
|
|
|
applyConfigEnvVars(cfg, deps.env);
|
|
|
|
const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true;
|
|
if (enabled && !shouldDeferShellEnvFallback(deps.env)) {
|
|
loadShellEnvFallback({
|
|
enabled: true,
|
|
env: deps.env,
|
|
expectedKeys: resolveShellEnvExpectedKeys(deps.env),
|
|
logger: deps.logger,
|
|
timeoutMs: cfg.env?.shellEnv?.timeoutMs ?? resolveShellEnvFallbackTimeoutMs(deps.env),
|
|
});
|
|
}
|
|
|
|
const pendingSecret = AUTO_OWNER_DISPLAY_SECRET_BY_PATH.get(configPath);
|
|
const ownerDisplaySecretResolution = ensureOwnerDisplaySecret(
|
|
cfg,
|
|
() => pendingSecret ?? crypto.randomBytes(32).toString("hex"),
|
|
);
|
|
const cfgWithOwnerDisplaySecret = persistGeneratedOwnerDisplaySecret({
|
|
config: ownerDisplaySecretResolution.config,
|
|
configPath,
|
|
generatedSecret: ownerDisplaySecretResolution.generatedSecret,
|
|
logger: deps.logger,
|
|
state: {
|
|
pendingByPath: AUTO_OWNER_DISPLAY_SECRET_BY_PATH,
|
|
persistInFlight: AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT,
|
|
persistWarned: AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED,
|
|
},
|
|
persistConfig: (nextConfig, options) => writeConfigFile(nextConfig, options),
|
|
});
|
|
|
|
return applyConfigOverrides(cfgWithOwnerDisplaySecret);
|
|
}
|
|
|
|
function captureFileSnapshotSync(filePath: string):
|
|
| {
|
|
existed: false;
|
|
}
|
|
| {
|
|
existed: true;
|
|
raw: string;
|
|
} {
|
|
return deps.fs.existsSync(filePath)
|
|
? ({
|
|
existed: true,
|
|
raw: deps.fs.readFileSync(filePath, "utf-8"),
|
|
} as const)
|
|
: ({ existed: false } as const);
|
|
}
|
|
|
|
function restoreFileSnapshotSync(
|
|
filePath: string,
|
|
previousFile:
|
|
| {
|
|
existed: false;
|
|
}
|
|
| {
|
|
existed: true;
|
|
raw: string;
|
|
},
|
|
): void {
|
|
if (previousFile.existed) {
|
|
deps.fs.writeFileSync(filePath, previousFile.raw, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
deps.fs.unlinkSync(filePath);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
function replaceConfigFileSync(raw: string): void {
|
|
const dir = path.dirname(configPath);
|
|
deps.fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
const tmp = path.join(
|
|
dir,
|
|
`${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
|
);
|
|
try {
|
|
deps.fs.writeFileSync(tmp, raw, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
try {
|
|
deps.fs.renameSync(tmp, configPath);
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException)?.code;
|
|
if (code !== "EPERM" && code !== "EEXIST") {
|
|
throw err;
|
|
}
|
|
deps.fs.copyFileSync(tmp, configPath);
|
|
deps.fs.chmodSync(configPath, 0o600);
|
|
deps.fs.unlinkSync(tmp);
|
|
}
|
|
} catch (err) {
|
|
try {
|
|
deps.fs.unlinkSync(tmp);
|
|
} catch (cleanupErr) {
|
|
if ((cleanupErr as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
deps.logger.warn(`Failed to clean temporary config file ${tmp}: ${String(cleanupErr)}`);
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function migrateAndStripShippedPluginInstallConfigRecords(
|
|
configRaw: unknown,
|
|
options: { persist?: boolean; rootConfigRaw?: unknown } = {},
|
|
): ShippedPluginInstallConfigReadMigration {
|
|
const installRecords = extractShippedPluginInstallConfigRecords(configRaw);
|
|
const stripped = stripShippedPluginInstallConfigRecords(configRaw);
|
|
if (Object.keys(installRecords).length === 0) {
|
|
return { config: stripped };
|
|
}
|
|
if (options.persist === false) {
|
|
return { config: stripped };
|
|
}
|
|
|
|
try {
|
|
const stateDir = resolveStateDir(deps.env, deps.homedir);
|
|
const filePath = resolveInstalledPluginIndexRecordsStorePath({
|
|
env: deps.env,
|
|
stateDir,
|
|
});
|
|
const previousFile = captureFileSnapshotSync(filePath);
|
|
const existingRecords = loadInstalledPluginIndexInstallRecordsSync({
|
|
env: deps.env,
|
|
stateDir,
|
|
});
|
|
const nextRecords = {
|
|
...installRecords,
|
|
...existingRecords,
|
|
};
|
|
if (Object.keys(installRecords).some((pluginId) => !(pluginId in existingRecords))) {
|
|
writePersistedInstalledPluginIndexInstallRecordsSync(nextRecords, {
|
|
config: coerceConfig(stripped),
|
|
env: deps.env,
|
|
stateDir,
|
|
});
|
|
}
|
|
const rootConfigRaw = options.rootConfigRaw;
|
|
if (
|
|
rootConfigRaw !== undefined &&
|
|
Object.keys(extractShippedPluginInstallConfigRecords(rootConfigRaw)).length > 0
|
|
) {
|
|
const persistedRootParsed = stripShippedPluginInstallConfigRecords(rootConfigRaw);
|
|
const persistedRootRaw = JSON.stringify(persistedRootParsed, null, 2)
|
|
.trimEnd()
|
|
.concat("\n");
|
|
try {
|
|
replaceConfigFileSync(persistedRootRaw);
|
|
} catch (err) {
|
|
restoreFileSnapshotSync(filePath, previousFile);
|
|
throw err;
|
|
}
|
|
return { config: stripped, persistedRootParsed, persistedRootRaw };
|
|
}
|
|
} catch (err) {
|
|
deps.logger.warn(
|
|
`Config (${configPath}): could not migrate shipped plugins.installs records into the plugin index: ${formatErrorMessage(
|
|
err,
|
|
)}`,
|
|
);
|
|
return { config: configRaw };
|
|
}
|
|
|
|
return { config: stripped };
|
|
}
|
|
|
|
function ensureShippedPluginInstallConfigRecordsMigratedForWrite(
|
|
snapshot: ConfigFileSnapshot,
|
|
): ShippedPluginInstallConfigWriteMigration {
|
|
const installRecords = {
|
|
...extractShippedPluginInstallConfigRecords(snapshot.sourceConfig),
|
|
...extractShippedPluginInstallConfigRecords(snapshot.parsed),
|
|
};
|
|
if (Object.keys(installRecords).length === 0) {
|
|
return { migrated: false };
|
|
}
|
|
|
|
const stateDir = resolveStateDir(deps.env, deps.homedir);
|
|
const filePath = resolveInstalledPluginIndexRecordsStorePath({
|
|
env: deps.env,
|
|
stateDir,
|
|
});
|
|
const existingRecords = loadInstalledPluginIndexInstallRecordsSync({
|
|
env: deps.env,
|
|
stateDir,
|
|
});
|
|
if (Object.keys(installRecords).every((pluginId) => pluginId in existingRecords)) {
|
|
return { migrated: false };
|
|
}
|
|
|
|
const previousFile = deps.fs.existsSync(filePath)
|
|
? ({
|
|
existed: true,
|
|
raw: deps.fs.readFileSync(filePath, "utf-8"),
|
|
} as const)
|
|
: ({ existed: false } as const);
|
|
try {
|
|
writePersistedInstalledPluginIndexInstallRecordsSync(
|
|
{
|
|
...installRecords,
|
|
...existingRecords,
|
|
},
|
|
{
|
|
config: coerceConfig(stripShippedPluginInstallConfigRecords(snapshot.sourceConfig)),
|
|
env: deps.env,
|
|
stateDir,
|
|
},
|
|
);
|
|
return {
|
|
migrated: true,
|
|
filePath,
|
|
previousFile,
|
|
};
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Config write blocked: shipped plugins.installs records in ${configPath} could not be migrated into the plugin index. Fix state directory permissions or run openclaw plugins registry --refresh, then retry. ${formatErrorMessage(
|
|
err,
|
|
)}`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
}
|
|
|
|
function rollbackShippedPluginInstallConfigWriteMigration(
|
|
migration: ShippedPluginInstallConfigWriteMigration,
|
|
): void {
|
|
if (!migration.migrated) {
|
|
return;
|
|
}
|
|
if (migration.previousFile.existed) {
|
|
deps.fs.writeFileSync(migration.filePath, migration.previousFile.raw, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
deps.fs.unlinkSync(migration.filePath);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadConfig(): OpenClawConfig {
|
|
try {
|
|
maybeLoadDotEnvForConfig(deps.env);
|
|
if (!deps.fs.existsSync(configPath)) {
|
|
if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) {
|
|
loadShellEnvFallback({
|
|
enabled: true,
|
|
env: deps.env,
|
|
expectedKeys: resolveShellEnvExpectedKeys(deps.env),
|
|
logger: deps.logger,
|
|
timeoutMs: resolveShellEnvFallbackTimeoutMs(deps.env),
|
|
});
|
|
}
|
|
return {};
|
|
}
|
|
const raw = deps.fs.readFileSync(configPath, "utf-8");
|
|
const parsed = deps.json5.parse(raw);
|
|
const recovered = maybeRecoverSuspiciousConfigReadSync({
|
|
deps,
|
|
configPath,
|
|
raw,
|
|
parsed,
|
|
});
|
|
const effectiveRaw = recovered.raw;
|
|
const effectiveParsed = recovered.parsed;
|
|
const readResolution = resolveConfigForRead(
|
|
resolveConfigIncludesForRead(effectiveParsed, configPath, deps),
|
|
deps.env,
|
|
);
|
|
const resolvedConfig = readResolution.resolvedConfigRaw;
|
|
const legacyResolution = resolveLegacyConfigForRead(resolvedConfig, effectiveParsed);
|
|
const installMigration = migrateAndStripShippedPluginInstallConfigRecords(
|
|
legacyResolution.effectiveConfigRaw,
|
|
{ rootConfigRaw: effectiveParsed },
|
|
);
|
|
const effectiveConfigRaw = installMigration.config;
|
|
const snapshotRaw = installMigration.persistedRootRaw ?? effectiveRaw;
|
|
const snapshotParsed = installMigration.persistedRootParsed ?? effectiveParsed;
|
|
const hash = hashConfigRaw(snapshotRaw);
|
|
for (const w of readResolution.envWarnings) {
|
|
deps.logger.warn(
|
|
`Config (${configPath}): missing env var "${w.varName}" at ${w.configPath} - feature using this value will be unavailable`,
|
|
);
|
|
}
|
|
warnOnConfigMiskeys(effectiveConfigRaw, deps.logger);
|
|
if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) {
|
|
observeLoadConfigSnapshot({
|
|
...createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: true,
|
|
raw: snapshotRaw,
|
|
parsed: snapshotParsed,
|
|
sourceConfig: {},
|
|
valid: true,
|
|
runtimeConfig: {},
|
|
hash,
|
|
issues: [],
|
|
warnings: [],
|
|
legacyIssues: legacyResolution.sourceLegacyIssues,
|
|
}),
|
|
});
|
|
return {};
|
|
}
|
|
const preValidationDuplicates = findDuplicateAgentDirs(effectiveConfigRaw as OpenClawConfig, {
|
|
env: deps.env,
|
|
homedir: deps.homedir,
|
|
});
|
|
if (preValidationDuplicates.length > 0) {
|
|
throw new DuplicateAgentDirError(preValidationDuplicates);
|
|
}
|
|
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, {
|
|
env: deps.env,
|
|
pluginValidation: overrides.pluginValidation,
|
|
});
|
|
if (!validated.ok) {
|
|
observeLoadConfigSnapshot({
|
|
...createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: true,
|
|
raw: snapshotRaw,
|
|
parsed: snapshotParsed,
|
|
sourceConfig: coerceConfig(effectiveConfigRaw),
|
|
valid: false,
|
|
runtimeConfig: coerceConfig(effectiveConfigRaw),
|
|
hash,
|
|
issues: validated.issues,
|
|
warnings: validated.warnings,
|
|
legacyIssues: legacyResolution.sourceLegacyIssues,
|
|
}),
|
|
});
|
|
throwInvalidConfig({
|
|
configPath,
|
|
issues: validated.issues,
|
|
logger: deps.logger,
|
|
loggedConfigPaths: loggedInvalidConfigs,
|
|
});
|
|
}
|
|
if (validated.warnings.length > 0) {
|
|
const details = validated.warnings
|
|
.map(
|
|
(iss) =>
|
|
`- ${sanitizeTerminalText(iss.path || "<root>")}: ${sanitizeTerminalText(iss.message)}`,
|
|
)
|
|
.join("\n");
|
|
deps.logger.warn(`Config warnings:\n${details}`);
|
|
}
|
|
warnIfConfigFromFuture(validated.config, deps.logger);
|
|
const cfg = materializeRuntimeConfig(validated.config, "load");
|
|
observeLoadConfigSnapshot({
|
|
...createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: true,
|
|
raw: snapshotRaw,
|
|
parsed: snapshotParsed,
|
|
sourceConfig: coerceConfig(effectiveConfigRaw),
|
|
valid: true,
|
|
runtimeConfig: cfg,
|
|
hash,
|
|
issues: [],
|
|
warnings: validated.warnings,
|
|
legacyIssues: legacyResolution.sourceLegacyIssues,
|
|
}),
|
|
});
|
|
return finalizeLoadedRuntimeConfig(cfg);
|
|
} catch (err) {
|
|
if (err instanceof DuplicateAgentDirError) {
|
|
deps.logger.error(err.message);
|
|
throw err;
|
|
}
|
|
const error = err as { code?: string };
|
|
if (error?.code === "INVALID_CONFIG") {
|
|
// Fail closed so invalid configs cannot silently fall back to permissive defaults.
|
|
throw err;
|
|
}
|
|
deps.logger.error(`Failed to read config at ${configPath}`, err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function readConfigFileSnapshotInternal(
|
|
options: { persistShippedPluginInstallMigration?: boolean } = {},
|
|
): Promise<ReadConfigFileSnapshotInternalResult> {
|
|
maybeLoadDotEnvForConfig(deps.env);
|
|
const exists = deps.fs.existsSync(configPath);
|
|
if (!exists) {
|
|
const hash = hashConfigRaw(null);
|
|
const config = {};
|
|
const legacyIssues: LegacyConfigIssue[] = [];
|
|
return await finalizeReadConfigSnapshotInternalResult(deps, {
|
|
snapshot: createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: false,
|
|
raw: null,
|
|
parsed: {},
|
|
sourceConfig: {},
|
|
valid: true,
|
|
runtimeConfig: config,
|
|
hash,
|
|
issues: [],
|
|
warnings: [],
|
|
legacyIssues,
|
|
}),
|
|
});
|
|
}
|
|
|
|
let fallbackRaw: string | null = null;
|
|
let fallbackParsed: unknown = {};
|
|
let fallbackSourceConfig: OpenClawConfig = {};
|
|
let fallbackHash = hashConfigRaw(null);
|
|
|
|
try {
|
|
const raw = await deps.measure("config.snapshot.read.file", () =>
|
|
deps.fs.readFileSync(configPath, "utf-8"),
|
|
);
|
|
const rawHash = await deps.measure("config.snapshot.read.hash", () => hashConfigRaw(raw));
|
|
fallbackRaw = raw;
|
|
fallbackHash = rawHash;
|
|
const parsedRes = await deps.measure("config.snapshot.read.parse", () =>
|
|
parseConfigJson5(raw, deps.json5),
|
|
);
|
|
if (!parsedRes.ok) {
|
|
return await finalizeReadConfigSnapshotInternalResult(deps, {
|
|
snapshot: createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: true,
|
|
raw,
|
|
parsed: {},
|
|
sourceConfig: {},
|
|
valid: false,
|
|
runtimeConfig: {},
|
|
hash: rawHash,
|
|
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
|
|
warnings: [],
|
|
legacyIssues: [],
|
|
}),
|
|
});
|
|
}
|
|
fallbackParsed = parsedRes.parsed;
|
|
fallbackSourceConfig = coerceConfig(parsedRes.parsed);
|
|
|
|
// Resolve $include directives
|
|
const recovered = await deps.measure("config.snapshot.read.recovery-check", () =>
|
|
maybeRecoverSuspiciousConfigRead({
|
|
deps,
|
|
configPath,
|
|
raw,
|
|
parsed: parsedRes.parsed,
|
|
}),
|
|
);
|
|
const effectiveRaw = recovered.raw;
|
|
const effectiveParsed = recovered.parsed;
|
|
const hash = hashConfigRaw(effectiveRaw);
|
|
fallbackRaw = effectiveRaw;
|
|
fallbackParsed = effectiveParsed;
|
|
fallbackSourceConfig = coerceConfig(effectiveParsed);
|
|
fallbackHash = hash;
|
|
|
|
let resolved: unknown;
|
|
try {
|
|
resolved = await deps.measure("config.snapshot.read.includes", () =>
|
|
resolveConfigIncludesForRead(effectiveParsed, configPath, deps),
|
|
);
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof ConfigIncludeError
|
|
? err.message
|
|
: `Include resolution failed: ${String(err)}`;
|
|
return await finalizeReadConfigSnapshotInternalResult(deps, {
|
|
snapshot: createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: true,
|
|
raw: effectiveRaw,
|
|
parsed: effectiveParsed,
|
|
// Keep the recovered root file payload here when read healing kicked in.
|
|
sourceConfig: coerceConfig(effectiveParsed),
|
|
valid: false,
|
|
runtimeConfig: coerceConfig(effectiveParsed),
|
|
hash,
|
|
issues: [{ path: "", message }],
|
|
warnings: [],
|
|
legacyIssues: [],
|
|
}),
|
|
});
|
|
}
|
|
|
|
const readResolution = await deps.measure("config.snapshot.read.env", () =>
|
|
resolveConfigForRead(resolved, deps.env),
|
|
);
|
|
|
|
// Convert missing env var references to config warnings instead of fatal errors.
|
|
// This allows the gateway to start in degraded mode when non-critical config
|
|
// sections reference unset env vars (e.g. optional provider API keys).
|
|
const envVarWarnings = readResolution.envWarnings.map((w) => ({
|
|
path: w.configPath,
|
|
message: `Missing env var "${w.varName}" - feature using this value will be unavailable`,
|
|
}));
|
|
|
|
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
|
|
const legacyResolution = await deps.measure("config.snapshot.read.legacy", () =>
|
|
resolveLegacyConfigForRead(resolvedConfigRaw, effectiveParsed),
|
|
);
|
|
const installMigration = await deps.measure(
|
|
"config.snapshot.read.plugin-install-migration",
|
|
() =>
|
|
migrateAndStripShippedPluginInstallConfigRecords(legacyResolution.effectiveConfigRaw, {
|
|
persist: options.persistShippedPluginInstallMigration !== false,
|
|
rootConfigRaw: effectiveParsed,
|
|
}),
|
|
);
|
|
const effectiveConfigRaw = installMigration.config;
|
|
const snapshotRaw = installMigration.persistedRootRaw ?? effectiveRaw;
|
|
const snapshotParsed = installMigration.persistedRootParsed ?? effectiveParsed;
|
|
const snapshotHash = installMigration.persistedRootRaw
|
|
? hashConfigRaw(installMigration.persistedRootRaw)
|
|
: hash;
|
|
fallbackSourceConfig = coerceConfig(effectiveConfigRaw);
|
|
let pluginMetadataSnapshot: PluginMetadataSnapshot | undefined;
|
|
const loadValidationPluginMetadataSnapshot = (config: OpenClawConfig) => {
|
|
if (pluginMetadataSnapshot) {
|
|
return pluginMetadataSnapshot;
|
|
}
|
|
const defaultAgentId = resolveDefaultAgentId(config);
|
|
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
|
|
config,
|
|
workspaceDir: resolveAgentWorkspaceDir(config, defaultAgentId),
|
|
env: deps.env,
|
|
});
|
|
return pluginMetadataSnapshot;
|
|
};
|
|
const validated = await deps.measure("config.snapshot.read.validate", () =>
|
|
validateConfigObjectWithPlugins(effectiveConfigRaw, {
|
|
env: deps.env,
|
|
pluginValidation: overrides.pluginValidation,
|
|
loadPluginMetadataSnapshot: loadValidationPluginMetadataSnapshot,
|
|
}),
|
|
);
|
|
if (!validated.ok) {
|
|
return await finalizeReadConfigSnapshotInternalResult(deps, {
|
|
snapshot: createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: true,
|
|
raw: snapshotRaw,
|
|
parsed: snapshotParsed,
|
|
sourceConfig: coerceConfig(effectiveConfigRaw),
|
|
valid: false,
|
|
runtimeConfig: coerceConfig(effectiveConfigRaw),
|
|
hash: snapshotHash,
|
|
issues: validated.issues,
|
|
warnings: [...validated.warnings, ...envVarWarnings],
|
|
legacyIssues: legacyResolution.sourceLegacyIssues,
|
|
}),
|
|
});
|
|
}
|
|
|
|
warnIfConfigFromFuture(validated.config, deps.logger);
|
|
const snapshotConfig = await deps.measure("config.snapshot.read.materialize", () =>
|
|
materializeRuntimeConfig(validated.config, "snapshot"),
|
|
);
|
|
return await deps.measure("config.snapshot.read.observe", () =>
|
|
finalizeReadConfigSnapshotInternalResult(deps, {
|
|
snapshot: createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: true,
|
|
raw: snapshotRaw,
|
|
parsed: snapshotParsed,
|
|
// Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults)
|
|
// for config set/unset operations (issue #6070)
|
|
sourceConfig: coerceConfig(effectiveConfigRaw),
|
|
valid: true,
|
|
runtimeConfig: snapshotConfig,
|
|
hash: snapshotHash,
|
|
issues: [],
|
|
warnings: [...validated.warnings, ...envVarWarnings],
|
|
legacyIssues: legacyResolution.sourceLegacyIssues,
|
|
}),
|
|
envSnapshotForRestore: readResolution.envSnapshotForRestore,
|
|
pluginMetadataSnapshot,
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
const nodeErr = err as NodeJS.ErrnoException;
|
|
let message: string;
|
|
if (nodeErr?.code === "EACCES") {
|
|
// Permission denied - common in Docker/container deployments where the
|
|
// config file is owned by root but the gateway runs as a non-root user.
|
|
const uid = process.getuid?.();
|
|
const uidHint = typeof uid === "number" ? String(uid) : "$(id -u)";
|
|
message = [
|
|
`read failed: ${String(err)}`,
|
|
``,
|
|
`Config file is not readable by the current process. If running in a container`,
|
|
`or 1-click deployment, fix ownership with:`,
|
|
` chown ${uidHint} "${configPath}"`,
|
|
`Then restart the gateway.`,
|
|
].join("\n");
|
|
deps.logger.error(message);
|
|
} else {
|
|
message = `read failed: ${String(err)}`;
|
|
}
|
|
return await finalizeReadConfigSnapshotInternalResult(deps, {
|
|
snapshot: createConfigFileSnapshot({
|
|
path: configPath,
|
|
exists: true,
|
|
raw: fallbackRaw,
|
|
parsed: fallbackParsed,
|
|
sourceConfig: fallbackSourceConfig,
|
|
valid: false,
|
|
runtimeConfig: fallbackSourceConfig,
|
|
hash: fallbackHash,
|
|
issues: [{ path: "", message }],
|
|
warnings: [],
|
|
legacyIssues: [],
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|
const result = await readConfigFileSnapshotInternal();
|
|
return result.snapshot;
|
|
}
|
|
|
|
async function readConfigFileSnapshotWithPluginMetadata(): Promise<ReadConfigFileSnapshotWithPluginMetadataResult> {
|
|
const result = await readConfigFileSnapshotInternal();
|
|
return {
|
|
snapshot: result.snapshot,
|
|
...(result.pluginMetadataSnapshot
|
|
? { pluginMetadataSnapshot: result.pluginMetadataSnapshot }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async function promoteConfigSnapshotToLastKnownGood(
|
|
snapshot: ConfigFileSnapshot,
|
|
): Promise<boolean> {
|
|
return await promoteConfigSnapshotToLastKnownGoodWithDeps({
|
|
deps,
|
|
snapshot,
|
|
logger: deps.logger,
|
|
});
|
|
}
|
|
|
|
async function recoverConfigFromLastKnownGood(params: {
|
|
snapshot: ConfigFileSnapshot;
|
|
reason: string;
|
|
}): Promise<boolean> {
|
|
return await recoverConfigFromLastKnownGoodWithDeps({
|
|
deps,
|
|
snapshot: params.snapshot,
|
|
reason: params.reason,
|
|
});
|
|
}
|
|
|
|
async function recoverConfigFromJsonRootSuffix(snapshot: ConfigFileSnapshot): Promise<boolean> {
|
|
return await recoverConfigFromJsonRootSuffixWithDeps({
|
|
deps,
|
|
configPath,
|
|
snapshot,
|
|
});
|
|
}
|
|
|
|
async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
|
|
const result = await readConfigFileSnapshotInternal({
|
|
persistShippedPluginInstallMigration: false,
|
|
});
|
|
return {
|
|
snapshot: result.snapshot,
|
|
writeOptions: {
|
|
envSnapshotForRestore: result.envSnapshotForRestore,
|
|
expectedConfigPath: configPath,
|
|
unsetPaths: resolveManagedUnsetPathsForWrite(undefined),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function readBestEffortConfig(): Promise<OpenClawConfig> {
|
|
const result = await readConfigFileSnapshotInternal();
|
|
if (!result.snapshot.valid) {
|
|
return result.snapshot.config;
|
|
}
|
|
return finalizeLoadedRuntimeConfig(
|
|
materializeRuntimeConfig(result.snapshot.sourceConfig, "load"),
|
|
);
|
|
}
|
|
|
|
async function readSourceConfigBestEffort(): Promise<OpenClawConfig> {
|
|
maybeLoadDotEnvForConfig(deps.env);
|
|
const exists = deps.fs.existsSync(configPath);
|
|
if (!exists) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const raw = deps.fs.readFileSync(configPath, "utf-8");
|
|
const parsedRes = parseConfigJson5(raw, deps.json5);
|
|
if (!parsedRes.ok) {
|
|
return {};
|
|
}
|
|
|
|
const recovered = await maybeRecoverSuspiciousConfigRead({
|
|
deps,
|
|
configPath,
|
|
raw,
|
|
parsed: parsedRes.parsed,
|
|
});
|
|
|
|
let resolved: unknown;
|
|
try {
|
|
resolved = resolveConfigIncludesForRead(recovered.parsed, configPath, deps);
|
|
} catch {
|
|
return coerceConfig(recovered.parsed);
|
|
}
|
|
|
|
const readResolution = resolveConfigForRead(resolved, deps.env);
|
|
const legacyResolution = resolveLegacyConfigForRead(
|
|
readResolution.resolvedConfigRaw,
|
|
recovered.parsed,
|
|
);
|
|
return coerceConfig(
|
|
stripShippedPluginInstallConfigRecords(legacyResolution.effectiveConfigRaw),
|
|
);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function writeConfigFile(
|
|
cfg: OpenClawConfig,
|
|
options: ConfigWriteOptions = {},
|
|
): Promise<{ persistedHash: string; persistedConfig: OpenClawConfig }> {
|
|
clearConfigCache();
|
|
const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths);
|
|
let persistCandidate: unknown = cfg;
|
|
const snapshot =
|
|
options.baseSnapshot ??
|
|
(
|
|
await readConfigFileSnapshotInternal({
|
|
persistShippedPluginInstallMigration: false,
|
|
})
|
|
).snapshot;
|
|
let envRefMap: Map<string, string> | null = null;
|
|
let changedPaths: Set<string> | null = null;
|
|
if (snapshot.valid && snapshot.exists) {
|
|
persistCandidate = resolvePersistCandidateForWrite({
|
|
runtimeConfig: snapshot.config,
|
|
sourceConfig: snapshot.resolved,
|
|
nextConfig: cfg,
|
|
rootAuthoredConfig: snapshot.parsed,
|
|
unsetPaths,
|
|
});
|
|
try {
|
|
const resolvedIncludes = resolveConfigIncludes(
|
|
snapshot.parsed,
|
|
configPath,
|
|
{
|
|
readFile: (candidate) => deps.fs.readFileSync(candidate, "utf-8"),
|
|
readFileWithGuards: ({ includePath, resolvedPath, rootRealDir }) =>
|
|
readConfigIncludeFileWithGuards({
|
|
includePath,
|
|
resolvedPath,
|
|
rootRealDir,
|
|
ioFs: deps.fs,
|
|
}),
|
|
parseJson: (raw) => deps.json5.parse(raw),
|
|
},
|
|
{ allowedRoots: resolveIncludeRoots(deps.env, deps.homedir) },
|
|
);
|
|
const collected = new Map<string, string>();
|
|
collectEnvRefPaths(resolvedIncludes, "", collected);
|
|
if (collected.size > 0) {
|
|
envRefMap = collected;
|
|
changedPaths = new Set<string>();
|
|
collectChangedPaths(snapshot.config, cfg, "", changedPaths);
|
|
}
|
|
} catch {
|
|
envRefMap = null;
|
|
}
|
|
}
|
|
|
|
persistCandidate = applyUnsetPathsForWrite(persistCandidate as OpenClawConfig, unsetPaths);
|
|
|
|
const validated = validateConfigObjectRawWithPlugins(persistCandidate, { env: deps.env });
|
|
if (!validated.ok) {
|
|
const issue = validated.issues[0];
|
|
const pathLabel = issue?.path ? issue.path : "<root>";
|
|
const issueMessage = issue?.message ?? "invalid";
|
|
throw new Error(formatConfigValidationFailure(pathLabel, issueMessage));
|
|
}
|
|
if (validated.warnings.length > 0) {
|
|
const details = validated.warnings
|
|
.map((warning) => `- ${warning.path}: ${warning.message}`)
|
|
.join("\n");
|
|
deps.logger.warn(`Config warnings:\n${details}`);
|
|
}
|
|
|
|
// Restore ${VAR} env var references that were resolved during config loading.
|
|
// Read the current file (pre-substitution) and restore any references whose
|
|
// resolved values match the incoming config - so we don't overwrite
|
|
// "${ANTHROPIC_API_KEY}" with "sk-ant-..." when the caller didn't change it.
|
|
//
|
|
// We use only the root file's parsed content (no $include resolution) to avoid
|
|
// pulling values from included files into the root config on write-back.
|
|
// Use persistCandidate (the merge-patched value before validation) rather than
|
|
// validated.config, because plugin/channel AJV validation may inject schema
|
|
// defaults (e.g., enrichGroupParticipantsFromContacts) that should not be
|
|
// persisted to disk (issue #56772).
|
|
// Apply legacy web-search normalization so that migration results are still
|
|
// persisted even though we bypass validated.config.
|
|
let cfgToWrite = persistCandidate as OpenClawConfig;
|
|
try {
|
|
if (deps.fs.existsSync(configPath)) {
|
|
const currentRaw = await deps.fs.promises.readFile(configPath, "utf-8");
|
|
const parsedRes = parseConfigJson5(currentRaw, deps.json5);
|
|
if (parsedRes.ok) {
|
|
// Use env snapshot from when config was loaded (if available) to avoid
|
|
// TOCTOU issues where env changes between load and write. Falls back to
|
|
// live env if no snapshot exists (e.g., first write before any load).
|
|
const envForRestore = options.envSnapshotForRestore ?? deps.env;
|
|
cfgToWrite = restoreEnvVarRefs(
|
|
cfgToWrite,
|
|
parsedRes.parsed,
|
|
envForRestore,
|
|
) as OpenClawConfig;
|
|
}
|
|
}
|
|
} catch {
|
|
// If reading the current file fails, write cfg as-is (no env restoration)
|
|
}
|
|
|
|
const dir = path.dirname(configPath);
|
|
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
await tightenStateDirPermissionsIfNeeded({
|
|
configPath,
|
|
env: deps.env,
|
|
homedir: deps.homedir,
|
|
fsModule: deps.fs,
|
|
});
|
|
const outputConfigBase =
|
|
envRefMap && changedPaths
|
|
? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig)
|
|
: cfgToWrite;
|
|
const outputConfig = applyUnsetPathsForWrite(outputConfigBase, unsetPaths);
|
|
// Do NOT apply runtime defaults when writing - user config should only contain
|
|
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
|
|
const stampedOutputConfig = stampConfigVersion(outputConfig);
|
|
const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n");
|
|
const nextHash = hashConfigRaw(json);
|
|
const previousHash = resolveConfigSnapshotHash(snapshot);
|
|
const changedPathCount = changedPaths?.size;
|
|
const previousBytes =
|
|
typeof snapshot.raw === "string" ? Buffer.byteLength(snapshot.raw, "utf-8") : null;
|
|
const nextBytes = Buffer.byteLength(json, "utf-8");
|
|
const previousStat = snapshot.exists
|
|
? await deps.fs.promises.stat(configPath).catch(() => null)
|
|
: null;
|
|
const hasMetaBefore = hasConfigMeta(snapshot.parsed);
|
|
const hasMetaAfter = hasConfigMeta(stampedOutputConfig);
|
|
const gatewayModeBefore = resolveGatewayMode(snapshot.resolved);
|
|
const gatewayModeAfter = resolveGatewayMode(stampedOutputConfig);
|
|
const suspiciousReasons = resolveConfigWriteSuspiciousReasons({
|
|
existsBefore: snapshot.exists,
|
|
previousBytes,
|
|
nextBytes,
|
|
hasMetaBefore,
|
|
gatewayModeBefore,
|
|
gatewayModeAfter,
|
|
});
|
|
const logConfigOverwrite = () => {
|
|
if (!snapshot.exists) {
|
|
return;
|
|
}
|
|
if (options.skipOutputLogs) {
|
|
return;
|
|
}
|
|
const isVitest = deps.env.VITEST === "true";
|
|
const shouldLogInVitest = deps.env.OPENCLAW_TEST_CONFIG_OVERWRITE_LOG === "1";
|
|
if (isVitest && !shouldLogInVitest) {
|
|
return;
|
|
}
|
|
deps.logger.warn(
|
|
formatConfigOverwriteLogMessage({
|
|
configPath,
|
|
previousHash: previousHash ?? null,
|
|
nextHash,
|
|
changedPathCount,
|
|
}),
|
|
);
|
|
};
|
|
const logConfigWriteAnomalies = () => {
|
|
if (suspiciousReasons.length === 0) {
|
|
return;
|
|
}
|
|
if (options.skipOutputLogs) {
|
|
return;
|
|
}
|
|
// Tests often write minimal configs (missing meta, etc); keep output quiet unless requested.
|
|
const isVitest = deps.env.VITEST === "true";
|
|
const shouldLogInVitest = deps.env.OPENCLAW_TEST_CONFIG_WRITE_ANOMALY_LOG === "1";
|
|
if (isVitest && !shouldLogInVitest) {
|
|
return;
|
|
}
|
|
deps.logger.warn(`Config write anomaly: ${configPath} (${suspiciousReasons.join(", ")})`);
|
|
};
|
|
const previousMetadata = resolveConfigStatMetadata(previousStat);
|
|
const auditRecordBase = createConfigWriteAuditRecordBase({
|
|
configPath,
|
|
env: deps.env,
|
|
existsBefore: snapshot.exists,
|
|
previousHash: previousHash ?? null,
|
|
nextHash,
|
|
previousBytes,
|
|
nextBytes,
|
|
previousMetadata,
|
|
changedPathCount,
|
|
hasMetaBefore,
|
|
hasMetaAfter,
|
|
gatewayModeBefore,
|
|
gatewayModeAfter,
|
|
suspicious: suspiciousReasons,
|
|
});
|
|
const appendWriteAudit = async (
|
|
result: ConfigWriteAuditResult,
|
|
err?: unknown,
|
|
nextStat?: fs.Stats | null,
|
|
) => {
|
|
await appendConfigAuditRecord({
|
|
fs: deps.fs,
|
|
env: deps.env,
|
|
homedir: deps.homedir,
|
|
record: finalizeConfigWriteAuditRecord({
|
|
base: auditRecordBase,
|
|
result,
|
|
err,
|
|
nextMetadata: resolveConfigStatMetadata(nextStat ?? null),
|
|
}),
|
|
});
|
|
};
|
|
const blockingReasons = resolveConfigWriteBlockingReasons(suspiciousReasons, options);
|
|
if (blockingReasons.length > 0 && options.allowDestructiveWrite !== true) {
|
|
const rejectedPath = `${configPath}.rejected.${formatConfigArtifactTimestamp(new Date().toISOString())}`;
|
|
await deps.fs.promises
|
|
.writeFile(rejectedPath, json, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
flag: "wx",
|
|
})
|
|
.catch(() => {});
|
|
const message = `Config write rejected: ${configPath} (${blockingReasons.join(", ")}). Rejected payload saved to ${rejectedPath}.`;
|
|
const err = Object.assign(new Error(message), {
|
|
code: "CONFIG_WRITE_REJECTED",
|
|
rejectedPath,
|
|
reasons: blockingReasons,
|
|
});
|
|
deps.logger.warn(message);
|
|
await appendWriteAudit("rejected", err);
|
|
throw err;
|
|
}
|
|
|
|
const tmp = path.join(
|
|
dir,
|
|
`${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
|
|
);
|
|
|
|
const pluginInstallConfigMigration =
|
|
ensureShippedPluginInstallConfigRecordsMigratedForWrite(snapshot);
|
|
let configCommitted = false;
|
|
try {
|
|
await deps.fs.promises.writeFile(tmp, json, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
|
|
if (deps.fs.existsSync(configPath)) {
|
|
await maintainConfigBackups(configPath, deps.fs.promises);
|
|
}
|
|
|
|
try {
|
|
await deps.fs.promises.rename(tmp, configPath);
|
|
} catch (err) {
|
|
const code = (err as { code?: string }).code;
|
|
// Windows doesn't reliably support atomic replace via rename when dest exists.
|
|
if (code === "EPERM" || code === "EEXIST") {
|
|
await deps.fs.promises.copyFile(tmp, configPath);
|
|
await deps.fs.promises.chmod(configPath, 0o600).catch(() => {
|
|
// best-effort
|
|
});
|
|
await deps.fs.promises.unlink(tmp).catch(() => {
|
|
// best-effort
|
|
});
|
|
configCommitted = true;
|
|
logConfigOverwrite();
|
|
logConfigWriteAnomalies();
|
|
await appendWriteAudit(
|
|
"copy-fallback",
|
|
undefined,
|
|
await deps.fs.promises.stat(configPath).catch(() => null),
|
|
);
|
|
return { persistedHash: nextHash, persistedConfig: stampedOutputConfig };
|
|
}
|
|
await deps.fs.promises.unlink(tmp).catch(() => {
|
|
// best-effort
|
|
});
|
|
throw err;
|
|
}
|
|
configCommitted = true;
|
|
logConfigOverwrite();
|
|
logConfigWriteAnomalies();
|
|
await appendWriteAudit(
|
|
"rename",
|
|
undefined,
|
|
await deps.fs.promises.stat(configPath).catch(() => null),
|
|
);
|
|
return { persistedHash: nextHash, persistedConfig: stampedOutputConfig };
|
|
} catch (err) {
|
|
if (!configCommitted) {
|
|
rollbackShippedPluginInstallConfigWriteMigration(pluginInstallConfigMigration);
|
|
}
|
|
await appendWriteAudit("failed", err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return {
|
|
configPath,
|
|
loadConfig,
|
|
readBestEffortConfig,
|
|
readSourceConfigBestEffort,
|
|
readConfigFileSnapshot,
|
|
readConfigFileSnapshotWithPluginMetadata,
|
|
readConfigFileSnapshotForWrite,
|
|
promoteConfigSnapshotToLastKnownGood,
|
|
recoverConfigFromLastKnownGood,
|
|
recoverConfigFromJsonRootSuffix,
|
|
writeConfigFile,
|
|
};
|
|
}
|
|
|
|
// NOTE: These wrappers intentionally do *not* cache the resolved config path at
|
|
// module scope. `OPENCLAW_CONFIG_PATH` (and friends) are expected to work even
|
|
// when set after the module has been imported (tests, one-off scripts, etc.).
|
|
const AUTO_OWNER_DISPLAY_SECRET_BY_PATH = new Map<string, string>();
|
|
const AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT = new Set<string>();
|
|
const AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED = new Set<string>();
|
|
export function clearConfigCache(): void {
|
|
// Compat shim: runtime snapshot is the only in-process cache now.
|
|
}
|
|
|
|
export function registerConfigWriteListener(
|
|
listener: (event: ConfigWriteNotification) => void,
|
|
): () => void {
|
|
return registerRuntimeConfigWriteListener(listener);
|
|
}
|
|
|
|
function isCompatibleTopLevelRuntimeProjectionShape(params: {
|
|
runtimeSnapshot: OpenClawConfig;
|
|
candidate: OpenClawConfig;
|
|
}): boolean {
|
|
const runtime = params.runtimeSnapshot as Record<string, unknown>;
|
|
const candidate = params.candidate as Record<string, unknown>;
|
|
for (const key of Object.keys(runtime)) {
|
|
if (!Object.hasOwn(candidate, key)) {
|
|
return false;
|
|
}
|
|
const runtimeValue = runtime[key];
|
|
const candidateValue = candidate[key];
|
|
const runtimeType = Array.isArray(runtimeValue)
|
|
? "array"
|
|
: runtimeValue === null
|
|
? "null"
|
|
: typeof runtimeValue;
|
|
const candidateType = Array.isArray(candidateValue)
|
|
? "array"
|
|
: candidateValue === null
|
|
? "null"
|
|
: typeof candidateValue;
|
|
if (runtimeType !== candidateType) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function projectConfigOntoRuntimeSourceSnapshot(config: OpenClawConfig): OpenClawConfig {
|
|
const runtimeConfigSnapshot = getRuntimeConfigSnapshotState();
|
|
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState();
|
|
if (!runtimeConfigSnapshot || !runtimeConfigSourceSnapshot) {
|
|
return config;
|
|
}
|
|
if (config === runtimeConfigSnapshot) {
|
|
return runtimeConfigSourceSnapshot;
|
|
}
|
|
// This projection expects callers to pass config objects derived from the
|
|
// active runtime snapshot (for example shallow/deep clones with targeted edits).
|
|
// For structurally unrelated configs, skip projection to avoid accidental
|
|
// merge-patch deletions or reintroducing resolved values into source refs.
|
|
if (
|
|
!isCompatibleTopLevelRuntimeProjectionShape({
|
|
runtimeSnapshot: runtimeConfigSnapshot,
|
|
candidate: config,
|
|
})
|
|
) {
|
|
return config;
|
|
}
|
|
const projectedSource = coerceConfig(
|
|
projectSourceOntoRuntimeShape(runtimeConfigSourceSnapshot, runtimeConfigSnapshot),
|
|
);
|
|
const runtimePatch = createMergePatch(runtimeConfigSnapshot, config);
|
|
return coerceConfig(applyMergePatch(projectedSource, runtimePatch));
|
|
}
|
|
|
|
export function loadConfig(): OpenClawConfig {
|
|
// First successful load becomes the process snapshot. Long-lived runtimes
|
|
// should swap this snapshot via explicit reload/watcher paths instead of
|
|
// reparsing openclaw.json on hot code paths.
|
|
return loadPinnedRuntimeConfig(() => createConfigIO().loadConfig());
|
|
}
|
|
|
|
export function getRuntimeConfig(): OpenClawConfig {
|
|
return loadConfig();
|
|
}
|
|
|
|
export async function readBestEffortConfig(): Promise<OpenClawConfig> {
|
|
return await createConfigIO().readBestEffortConfig();
|
|
}
|
|
|
|
export async function readSourceConfigBestEffort(): Promise<OpenClawConfig> {
|
|
return await createConfigIO().readSourceConfigBestEffort();
|
|
}
|
|
|
|
export async function readConfigFileSnapshot(options?: {
|
|
measure?: ConfigSnapshotReadMeasure;
|
|
}): Promise<ConfigFileSnapshot> {
|
|
return await createConfigIO(
|
|
options?.measure ? { measure: options.measure } : {},
|
|
).readConfigFileSnapshot();
|
|
}
|
|
|
|
export async function readConfigFileSnapshotWithPluginMetadata(options?: {
|
|
measure?: ConfigSnapshotReadMeasure;
|
|
}): Promise<ReadConfigFileSnapshotWithPluginMetadataResult> {
|
|
return await createConfigIO(
|
|
options?.measure ? { measure: options.measure } : {},
|
|
).readConfigFileSnapshotWithPluginMetadata();
|
|
}
|
|
|
|
export async function promoteConfigSnapshotToLastKnownGood(
|
|
snapshot: ConfigFileSnapshot,
|
|
): Promise<boolean> {
|
|
return await createConfigIO().promoteConfigSnapshotToLastKnownGood(snapshot);
|
|
}
|
|
|
|
export async function recoverConfigFromLastKnownGood(params: {
|
|
snapshot: ConfigFileSnapshot;
|
|
reason: string;
|
|
}): Promise<boolean> {
|
|
return await createConfigIO().recoverConfigFromLastKnownGood(params);
|
|
}
|
|
|
|
export async function recoverConfigFromJsonRootSuffix(
|
|
snapshot: ConfigFileSnapshot,
|
|
): Promise<boolean> {
|
|
return await createConfigIO().recoverConfigFromJsonRootSuffix(snapshot);
|
|
}
|
|
|
|
export async function readSourceConfigSnapshot(): Promise<ConfigFileSnapshot> {
|
|
return await readConfigFileSnapshot();
|
|
}
|
|
|
|
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
|
|
return await createConfigIO().readConfigFileSnapshotForWrite();
|
|
}
|
|
|
|
export async function readSourceConfigSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
|
|
return await readConfigFileSnapshotForWrite();
|
|
}
|
|
|
|
export async function writeConfigFile(
|
|
cfg: OpenClawConfig,
|
|
options: ConfigWriteOptions = {},
|
|
): Promise<void> {
|
|
const io = createConfigIO();
|
|
let nextCfg = cfg;
|
|
const runtimeConfigSnapshot = getRuntimeConfigSnapshotState();
|
|
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState();
|
|
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
|
|
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
|
|
if (hadBothSnapshots) {
|
|
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
|
|
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
|
|
}
|
|
const writeResult = await io.writeConfigFile(nextCfg, {
|
|
envSnapshotForRestore: resolveWriteEnvSnapshotForPath({
|
|
actualConfigPath: io.configPath,
|
|
expectedConfigPath: options.expectedConfigPath,
|
|
envSnapshotForRestore: options.envSnapshotForRestore,
|
|
}),
|
|
unsetPaths: resolveManagedUnsetPathsForWrite(options.unsetPaths),
|
|
allowDestructiveWrite: options.allowDestructiveWrite,
|
|
allowConfigSizeDrop: options.allowConfigSizeDrop,
|
|
skipRuntimeSnapshotRefresh: options.skipRuntimeSnapshotRefresh,
|
|
skipOutputLogs: options.skipOutputLogs,
|
|
});
|
|
if (
|
|
options.skipRuntimeSnapshotRefresh &&
|
|
!hadRuntimeSnapshot &&
|
|
!getRuntimeConfigSnapshotRefreshHandlerState()
|
|
) {
|
|
return;
|
|
}
|
|
// Re-read the freshly persisted file so the sourceConfig we publish matches
|
|
// exactly what readConfigFileSnapshot() will produce when the file-watcher
|
|
// path next picks up an external edit. Without this, the in-process write
|
|
// path emits `nextCfg` (the pre-write source merge) while the file-watcher
|
|
// path emits a sourceConfig that has additionally been shaped by include/
|
|
// env resolution, legacy migration, and the shipped-plugin-install strip.
|
|
// The two diverge on schema-derived defaults that the read pipeline adds
|
|
// but `nextCfg` never sees, so the gateway reload pump's
|
|
// currentCompareConfig drifts permanently from on-disk state and diffs out
|
|
// phantom paths under plugins.entries.* on every save — incorrectly
|
|
// triggering a `plugins`-scoped restart of the gateway for changes that
|
|
// never touched any plugin entry.
|
|
let canonicalSourceConfig: OpenClawConfig = nextCfg;
|
|
try {
|
|
const freshSnapshot = await io.readConfigFileSnapshot();
|
|
if (freshSnapshot.exists && freshSnapshot.valid) {
|
|
canonicalSourceConfig = freshSnapshot.sourceConfig;
|
|
}
|
|
} catch {
|
|
// Best-effort; fall back to nextCfg so a transient read failure does not
|
|
// block the write notification.
|
|
}
|
|
const notifyCommittedWrite = () => {
|
|
const currentRuntimeConfig = getRuntimeConfigSnapshotState();
|
|
if (!currentRuntimeConfig) {
|
|
return;
|
|
}
|
|
notifyRuntimeConfigWriteListeners(
|
|
createRuntimeConfigWriteNotification({
|
|
configPath: io.configPath,
|
|
sourceConfig: canonicalSourceConfig,
|
|
runtimeConfig: currentRuntimeConfig,
|
|
persistedHash: writeResult.persistedHash,
|
|
afterWrite: options.afterWrite,
|
|
}),
|
|
);
|
|
};
|
|
// Keep the last-known-good runtime snapshot active until the specialized refresh path
|
|
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
|
|
await finalizeRuntimeSnapshotWrite({
|
|
nextSourceConfig: canonicalSourceConfig,
|
|
hadRuntimeSnapshot,
|
|
hadBothSnapshots,
|
|
loadFreshConfig: () => io.loadConfig(),
|
|
notifyCommittedWrite,
|
|
formatRefreshError: (error) => formatErrorMessage(error),
|
|
createRefreshError: (detail, cause) =>
|
|
new ConfigRuntimeRefreshError(
|
|
`Config was written to ${io.configPath}, but runtime snapshot refresh failed: ${detail}`,
|
|
{ cause },
|
|
),
|
|
});
|
|
}
|