refactor(config): centralize runtime config state

This commit is contained in:
Peter Steinberger 2026-03-30 00:13:59 +01:00
parent b888741462
commit 89a4f2a34e
No known key found for this signature in database
13 changed files with 366 additions and 256 deletions

View File

@ -11,6 +11,7 @@ import {
writeConfigFile,
} from "../../config/config.js";
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
import {
@ -1023,8 +1024,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
postUpdateConfigSnapshot = {
...configSnapshot,
parsed: next,
resolved: next,
config: next,
sourceConfig: asResolvedSourceConfig(next),
resolved: asResolvedSourceConfig(next),
runtimeConfig: asRuntimeConfig(next),
config: asRuntimeConfig(next),
};
if (!opts.json) {
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));

View File

@ -1,8 +1,8 @@
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getModelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import {
loadConfig,
readConfigFileSnapshotForWrite,
getRuntimeConfig,
readSourceConfigSnapshotForWrite,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../../config/config.js";
@ -16,9 +16,9 @@ export type LoadedModelsConfig = {
async function loadSourceConfigSnapshot(fallback: OpenClawConfig): Promise<OpenClawConfig> {
try {
const { snapshot } = await readConfigFileSnapshotForWrite();
const { snapshot } = await readSourceConfigSnapshotForWrite();
if (snapshot.valid) {
return snapshot.resolved;
return snapshot.sourceConfig;
}
} catch {
// Fall back to runtime-loaded config if source snapshot cannot be read.
@ -30,7 +30,7 @@ export async function loadModelsConfigWithSource(params: {
commandName: string;
runtime?: RuntimeEnv;
}): Promise<LoadedModelsConfig> {
const runtimeConfig = loadConfig();
const runtimeConfig = getRuntimeConfig();
const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig);
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: runtimeConfig,

View File

@ -4,6 +4,7 @@ export {
clearRuntimeConfigSnapshot,
registerConfigWriteListener,
createConfigIO,
getRuntimeConfig,
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
projectConfigOntoRuntimeSourceSnapshot,
@ -12,6 +13,8 @@ export {
parseConfigJson5,
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
readSourceConfigSnapshot,
readSourceConfigSnapshotForWrite,
resetConfigRuntimeState,
resolveConfigSnapshotHash,
setRuntimeConfigSnapshotRefreshHandler,
@ -20,6 +23,7 @@ export {
} from "./io.js";
export type { ConfigWriteNotification } from "./io.js";
export { migrateLegacyConfig } from "./legacy-migrate.js";
export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js";
export * from "./paths.js";
export * from "./runtime-overrides.js";
export * from "./types.js";

View File

@ -17,17 +17,6 @@ import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { VERSION } from "../version.js";
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
import { maintainConfigBackups } from "./backup-rotation.js";
import {
applyCompactionDefaults,
applyContextPruningDefaults,
applyAgentDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
applySessionDefaults,
applyTalkConfigNormalization,
applyTalkApiKey,
} from "./defaults.js";
import { restoreEnvVarRefs } from "./env-preserve.js";
import {
type EnvSubstitutionWarning,
@ -43,9 +32,12 @@ import {
} from "./includes.js";
import { migrateLegacyConfig } from "./legacy-migrate.js";
import { findLegacyConfigIssues } from "./legacy.js";
import {
asResolvedSourceConfig,
asRuntimeConfig,
materializeRuntimeConfig,
} from "./materialize.js";
import { applyMergePatch } from "./merge-patch.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
@ -249,6 +241,8 @@ export type ConfigWriteNotification = {
configPath: string;
sourceConfig: OpenClawConfig;
runtimeConfig: OpenClawConfig;
persistedHash: string;
writtenAtMs: number;
};
export class ConfigRuntimeRefreshError extends Error {
@ -1637,6 +1631,38 @@ type ReadConfigFileSnapshotInternalResult = {
envSnapshotForRestore?: Record<string, string | undefined>;
};
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,
@ -1700,17 +1726,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
warnOnConfigMiskeys(effectiveConfigRaw, deps.logger);
if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) {
observeLoadConfigSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
resolved: {},
valid: true,
config: {},
hash,
issues: [],
warnings: [],
legacyIssues: legacyResolution.sourceLegacyIssues,
...createConfigFileSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
sourceConfig: {},
valid: true,
runtimeConfig: {},
hash,
issues: [],
warnings: [],
legacyIssues: legacyResolution.sourceLegacyIssues,
}),
});
return {};
}
@ -1724,17 +1752,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
if (!validated.ok) {
observeLoadConfigSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
resolved: coerceConfig(effectiveConfigRaw),
valid: false,
config: coerceConfig(effectiveConfigRaw),
hash,
issues: validated.issues,
warnings: validated.warnings,
legacyIssues: legacyResolution.sourceLegacyIssues,
...createConfigFileSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
sourceConfig: coerceConfig(effectiveConfigRaw),
valid: false,
runtimeConfig: coerceConfig(effectiveConfigRaw),
hash,
issues: validated.issues,
warnings: validated.warnings,
legacyIssues: legacyResolution.sourceLegacyIssues,
}),
});
const details = validated.issues
.map(
@ -1761,31 +1791,21 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
deps.logger.warn(`Config warnings:\\n${details}`);
}
warnIfConfigFromFuture(validated.config, deps.logger);
const cfg = applyTalkConfigNormalization(
applyModelDefaults(
applyCompactionDefaults(
applyContextPruningDefaults(
applyAgentDefaults(
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
),
),
),
),
);
normalizeConfigPaths(cfg);
normalizeExecSafeBinProfilesInConfig(cfg);
const cfg = materializeRuntimeConfig(validated.config, "load");
observeLoadConfigSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
resolved: coerceConfig(effectiveConfigRaw),
valid: true,
config: cfg,
hash,
issues: [],
warnings: validated.warnings,
legacyIssues: legacyResolution.sourceLegacyIssues,
...createConfigFileSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
sourceConfig: coerceConfig(effectiveConfigRaw),
valid: true,
runtimeConfig: cfg,
hash,
issues: [],
warnings: validated.warnings,
legacyIssues: legacyResolution.sourceLegacyIssues,
}),
});
const duplicates = findDuplicateAgentDirs(cfg, {
@ -1865,32 +1885,22 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const exists = deps.fs.existsSync(configPath);
if (!exists) {
const hash = hashConfigRaw(null);
const config = applyTalkApiKey(
applyTalkConfigNormalization(
applyModelDefaults(
applyCompactionDefaults(
applyContextPruningDefaults(
applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))),
),
),
),
),
);
const config = materializeRuntimeConfig({}, "missing");
const legacyIssues: LegacyConfigIssue[] = [];
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: {
snapshot: createConfigFileSnapshot({
path: configPath,
exists: false,
raw: null,
parsed: {},
resolved: {},
sourceConfig: {},
valid: true,
config,
runtimeConfig: config,
hash,
issues: [],
warnings: [],
legacyIssues,
},
}),
});
}
@ -1900,19 +1910,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const parsedRes = parseConfigJson5(raw, deps.json5);
if (!parsedRes.ok) {
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: {
snapshot: createConfigFileSnapshot({
path: configPath,
exists: true,
raw,
parsed: {},
resolved: {},
sourceConfig: {},
valid: false,
config: {},
runtimeConfig: {},
hash: rawHash,
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
warnings: [],
legacyIssues: [],
},
}),
});
}
@ -1936,19 +1946,21 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
? err.message
: `Include resolution failed: ${String(err)}`;
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: {
snapshot: createConfigFileSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
resolved: coerceConfig(effectiveParsed),
sourceConfig: coerceConfig(parsedRes.parsed),
// Keep the recovered root file payload here when read healing kicked in.
sourceConfig: coerceConfig(effectiveParsed),
valid: false,
config: coerceConfig(effectiveParsed),
runtimeConfig: coerceConfig(effectiveParsed),
hash,
issues: [{ path: "", message }],
warnings: [],
legacyIssues: [],
},
}),
});
}
@ -1969,51 +1981,40 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env });
if (!validated.ok) {
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: {
snapshot: createConfigFileSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
resolved: coerceConfig(effectiveConfigRaw),
sourceConfig: coerceConfig(effectiveConfigRaw),
valid: false,
config: coerceConfig(effectiveConfigRaw),
runtimeConfig: coerceConfig(effectiveConfigRaw),
hash,
issues: validated.issues,
warnings: [...validated.warnings, ...envVarWarnings],
legacyIssues: legacyResolution.sourceLegacyIssues,
},
}),
});
}
warnIfConfigFromFuture(validated.config, deps.logger);
const snapshotConfig = normalizeConfigPaths(
applyTalkApiKey(
applyTalkConfigNormalization(
applyModelDefaults(
applyAgentDefaults(
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
),
),
),
),
);
normalizeExecSafeBinProfilesInConfig(snapshotConfig);
const snapshotConfig = materializeRuntimeConfig(validated.config, "snapshot");
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: {
snapshot: createConfigFileSnapshot({
path: configPath,
exists: true,
raw: effectiveRaw,
parsed: effectiveParsed,
// Use resolvedConfigRaw (after $include and ${ENV} substitution but BEFORE runtime defaults)
// for config set/unset operations (issue #6070)
resolved: coerceConfig(effectiveConfigRaw),
sourceConfig: coerceConfig(effectiveConfigRaw),
valid: true,
config: snapshotConfig,
runtimeConfig: snapshotConfig,
hash,
issues: [],
warnings: [...validated.warnings, ...envVarWarnings],
legacyIssues: legacyResolution.sourceLegacyIssues,
},
}),
envSnapshotForRestore: readResolution.envSnapshotForRestore,
});
} catch (err) {
@ -2037,19 +2038,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
message = `read failed: ${String(err)}`;
}
return await finalizeReadConfigSnapshotInternalResult(deps, {
snapshot: {
snapshot: createConfigFileSnapshot({
path: configPath,
exists: true,
raw: null,
parsed: {},
resolved: {},
sourceConfig: {},
valid: false,
config: {},
runtimeConfig: {},
hash: hashConfigRaw(null),
issues: [{ path: "", message }],
warnings: [],
legacyIssues: [],
},
}),
});
}
}
@ -2070,7 +2071,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
};
}
async function writeConfigFile(cfg: OpenClawConfig, options: ConfigWriteOptions = {}) {
async function writeConfigFile(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<{ persistedHash: string }> {
clearConfigCache();
let persistCandidate: unknown = cfg;
const { snapshot } = await readConfigFileSnapshotInternal();
@ -2333,7 +2337,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
undefined,
await deps.fs.promises.stat(configPath).catch(() => null),
);
return;
return { persistedHash: nextHash };
}
await deps.fs.promises.unlink(tmp).catch(() => {
// best-effort
@ -2347,6 +2351,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
undefined,
await deps.fs.promises.stat(configPath).catch(() => null),
);
return { persistedHash: nextHash };
} catch (err) {
await appendWriteAudit("failed", err);
throw err;
@ -2491,6 +2496,10 @@ export function loadConfig(): OpenClawConfig {
return runtimeConfigSnapshot ?? config;
}
export function getRuntimeConfig(): OpenClawConfig {
return loadConfig();
}
export async function readBestEffortConfig(): Promise<OpenClawConfig> {
const snapshot = await readConfigFileSnapshot();
return snapshot.valid ? loadConfig() : snapshot.config;
@ -2500,10 +2509,18 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
return await createConfigIO().readConfigFileSnapshot();
}
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 = {},
@ -2518,7 +2535,7 @@ export async function writeConfigFile(
}
const sameConfigPath =
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
await io.writeConfigFile(nextCfg, {
const writeResult = await io.writeConfigFile(nextCfg, {
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
unsetPaths: options.unsetPaths,
});
@ -2530,6 +2547,8 @@ export async function writeConfigFile(
configPath: io.configPath,
sourceConfig: nextCfg,
runtimeConfig: runtimeConfigSnapshot,
persistedHash: writeResult.persistedHash,
writtenAtMs: Date.now(),
});
};
// Keep the last-known-good runtime snapshot active until the specialized refresh path

85
src/config/materialize.ts Normal file
View File

@ -0,0 +1,85 @@
import {
applyCompactionDefaults,
applyContextPruningDefaults,
applyAgentDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
applySessionDefaults,
applyTalkApiKey,
applyTalkConfigNormalization,
} from "./defaults.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
import type { OpenClawConfig, ResolvedSourceConfig, RuntimeConfig } from "./types.js";
export type ConfigMaterializationMode = "load" | "missing" | "snapshot";
type MaterializationProfile = {
includeTalkApiKey: boolean;
includeCompactionDefaults: boolean;
includeContextPruningDefaults: boolean;
includeLoggingDefaults: boolean;
normalizePaths: boolean;
};
const MATERIALIZATION_PROFILES: Record<ConfigMaterializationMode, MaterializationProfile> = {
load: {
includeTalkApiKey: false,
includeCompactionDefaults: true,
includeContextPruningDefaults: true,
includeLoggingDefaults: true,
normalizePaths: true,
},
missing: {
includeTalkApiKey: true,
includeCompactionDefaults: true,
includeContextPruningDefaults: true,
includeLoggingDefaults: false,
normalizePaths: false,
},
snapshot: {
includeTalkApiKey: true,
includeCompactionDefaults: false,
includeContextPruningDefaults: false,
includeLoggingDefaults: true,
normalizePaths: true,
},
};
export function asResolvedSourceConfig(config: OpenClawConfig): ResolvedSourceConfig {
return config as ResolvedSourceConfig;
}
export function asRuntimeConfig(config: OpenClawConfig): RuntimeConfig {
return config as RuntimeConfig;
}
export function materializeRuntimeConfig(
config: OpenClawConfig,
mode: ConfigMaterializationMode,
): RuntimeConfig {
const profile = MATERIALIZATION_PROFILES[mode];
let next = applyMessageDefaults(config);
if (profile.includeLoggingDefaults) {
next = applyLoggingDefaults(next);
}
next = applySessionDefaults(next);
next = applyAgentDefaults(next);
if (profile.includeContextPruningDefaults) {
next = applyContextPruningDefaults(next);
}
if (profile.includeCompactionDefaults) {
next = applyCompactionDefaults(next);
}
next = applyModelDefaults(next);
next = applyTalkConfigNormalization(next);
if (profile.includeTalkApiKey) {
next = applyTalkApiKey(next);
}
if (profile.normalizePaths) {
normalizeConfigPaths(next);
}
normalizeExecSafeBinProfilesInConfig(next);
return asRuntimeConfig(next);
}

82
src/config/mutate.ts Normal file
View File

@ -0,0 +1,82 @@
import {
readConfigFileSnapshotForWrite,
resolveConfigSnapshotHash,
writeConfigFile,
type ConfigWriteOptions,
} from "./io.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js";
export type ConfigMutationBase = "runtime" | "source";
export class ConfigMutationConflictError extends Error {
readonly currentHash: string | null;
constructor(message: string, params: { currentHash: string | null }) {
super(message);
this.name = "ConfigMutationConflictError";
this.currentHash = params.currentHash;
}
}
export type ConfigReplaceResult = {
path: string;
previousHash: string | null;
snapshot: ConfigFileSnapshot;
nextConfig: OpenClawConfig;
};
function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: string): string | null {
const currentHash = resolveConfigSnapshotHash(snapshot) ?? null;
if (expectedHash !== undefined && expectedHash !== currentHash) {
throw new ConfigMutationConflictError("config changed since last load", {
currentHash,
});
}
return currentHash;
}
export async function replaceConfigFile(params: {
nextConfig: OpenClawConfig;
baseHash?: string;
writeOptions?: ConfigWriteOptions;
}): Promise<ConfigReplaceResult> {
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
await writeConfigFile(params.nextConfig, {
...writeOptions,
...params.writeOptions,
});
return {
path: snapshot.path,
previousHash,
snapshot,
nextConfig: params.nextConfig,
};
}
export async function mutateConfigFile<T = void>(params: {
base?: ConfigMutationBase;
baseHash?: string;
writeOptions?: ConfigWriteOptions;
mutate: (
draft: OpenClawConfig,
context: { snapshot: ConfigFileSnapshot; previousHash: string | null },
) => Promise<T | void> | T | void;
}): Promise<ConfigReplaceResult & { result: T | undefined }> {
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
const baseConfig = params.base === "runtime" ? snapshot.runtimeConfig : snapshot.sourceConfig;
const draft = structuredClone(baseConfig) as OpenClawConfig;
const result = (await params.mutate(draft, { snapshot, previousHash })) as T | undefined;
await writeConfigFile(draft, {
...writeOptions,
...params.writeOptions,
});
return {
path: snapshot.path,
previousHash,
snapshot,
nextConfig: draft,
result,
};
}

View File

@ -124,6 +124,16 @@ export type OpenClawConfig = {
mcp?: McpConfig;
};
declare const openClawConfigStateBrand: unique symbol;
type BrandedConfigState<TState extends string> = OpenClawConfig & {
readonly [openClawConfigStateBrand]?: TState;
};
export type SourceConfig = BrandedConfigState<"source">;
export type ResolvedSourceConfig = BrandedConfigState<"resolved-source">;
export type RuntimeConfig = BrandedConfigState<"runtime">;
export type ConfigValidationIssue = {
path: string;
message: string;
@ -141,14 +151,22 @@ export type ConfigFileSnapshot = {
exists: boolean;
raw: string | null;
parsed: unknown;
/**
* Config authored on disk after $include resolution and ${ENV} substitution,
* but BEFORE runtime defaults are applied.
*/
sourceConfig: ResolvedSourceConfig;
/**
* Config after $include resolution and ${ENV} substitution, but BEFORE runtime
* defaults are applied. Use this for config set/unset operations to avoid
* leaking runtime defaults into the written config file.
*/
resolved: OpenClawConfig;
resolved: ResolvedSourceConfig;
valid: boolean;
config: OpenClawConfig;
/** Runtime-shaped config used by in-process readers. */
runtimeConfig: RuntimeConfig;
/** @deprecated Prefer runtimeConfig. */
config: RuntimeConfig;
hash?: string;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];

View File

@ -21,14 +21,13 @@ import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net
import { isRecord } from "../utils.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
import { getBundledChannelConfigSchemaMap } from "./bundled-channel-config-runtime.js";
import { collectChannelSchemaMetadata } from "./channel-config-metadata.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
import {
listLegacyWebSearchConfigPaths,
normalizeLegacyWebSearchConfig,
} from "./legacy-web-search.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { materializeRuntimeConfig } from "./materialize.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { OpenClawSchema } from "./zod-schema.js";
@ -41,7 +40,6 @@ type AllowedValuesCollection = {
incomplete: boolean;
hasValues: boolean;
};
type JsonSchemaNode = Record<string, unknown>;
const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
@ -66,128 +64,7 @@ function formatConfigPath(segments: readonly ConfigPathSegment[]): string {
return segments.join(".");
}
function toJsonSchemaNode(value: unknown): JsonSchemaNode | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaNode;
}
function getSchemaCombinatorBranches(node: JsonSchemaNode): JsonSchemaNode[] {
const keys = ["anyOf", "oneOf", "allOf"] as const;
const branches: JsonSchemaNode[] = [];
for (const key of keys) {
const value = node[key];
if (!Array.isArray(value)) {
continue;
}
for (const entry of value) {
const child = toJsonSchemaNode(entry);
if (child) {
branches.push(child);
}
}
}
return branches;
}
function collectAllowedValuesFromSchemaNode(node: JsonSchemaNode): AllowedValuesCollection {
if (Object.prototype.hasOwnProperty.call(node, "const")) {
return { values: [node.const], incomplete: false, hasValues: true };
}
const enumValues = node.enum;
if (Array.isArray(enumValues)) {
return { values: enumValues, incomplete: false, hasValues: enumValues.length > 0 };
}
if (node.type === "boolean") {
return { values: [true, false], incomplete: false, hasValues: true };
}
const branches = getSchemaCombinatorBranches(node);
if (branches.length === 0) {
return { values: [], incomplete: true, hasValues: false };
}
const collected: unknown[] = [];
for (const branch of branches) {
const result = collectAllowedValuesFromSchemaNode(branch);
if (result.incomplete || !result.hasValues) {
return { values: [], incomplete: true, hasValues: false };
}
collected.push(...result.values);
}
return { values: collected, incomplete: false, hasValues: collected.length > 0 };
}
function advanceSchemaNodes(node: JsonSchemaNode, segment: ConfigPathSegment): JsonSchemaNode[] {
const branches = getSchemaCombinatorBranches(node);
if (branches.length > 0) {
return branches.flatMap((branch) => advanceSchemaNodes(branch, segment));
}
if (typeof segment === "number") {
const items = toJsonSchemaNode(node.items);
return items ? [items] : [];
}
const properties = toJsonSchemaNode(node.properties);
const propertyNode = properties ? toJsonSchemaNode(properties[segment]) : null;
if (propertyNode) {
return [propertyNode];
}
const additionalProperties = toJsonSchemaNode(node.additionalProperties);
return additionalProperties ? [additionalProperties] : [];
}
function collectAllowedValuesFromSchemaPath(
root: JsonSchemaNode,
path: readonly ConfigPathSegment[],
): AllowedValuesCollection {
let currentNodes = [root];
for (const segment of path) {
currentNodes = currentNodes.flatMap((node) => advanceSchemaNodes(node, segment));
if (currentNodes.length === 0) {
return { values: [], incomplete: false, hasValues: false };
}
}
const collected: unknown[] = [];
for (const node of currentNodes) {
const result = collectAllowedValuesFromSchemaNode(node);
if (result.incomplete || !result.hasValues) {
return { values: [], incomplete: true, hasValues: false };
}
collected.push(...result.values);
}
return { values: collected, incomplete: false, hasValues: collected.length > 0 };
}
function collectAllowedValuesFromConfigPath(
path: readonly ConfigPathSegment[],
): AllowedValuesCollection {
if (path[0] === "channels" && typeof path[1] === "string") {
const channelSchema = getBundledChannelConfigSchemaMap().get(path[1]);
const schemaRoot = toJsonSchemaNode(channelSchema?.schema);
if (schemaRoot) {
return collectAllowedValuesFromSchemaPath(schemaRoot, path.slice(2));
}
}
return { values: [], incomplete: false, hasValues: false };
}
function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection {
const path = toConfigPathSegments(record.path);
const schemaValues = collectAllowedValuesFromConfigPath(path);
if (schemaValues.hasValues && !schemaValues.incomplete) {
return schemaValues;
}
const message = typeof record.message === "string" ? record.message : "";
const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE);
if (expectedMatch?.[1]) {
@ -195,6 +72,9 @@ function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): Allowe
return { values, incomplete: false, hasValues: values.length > 0 };
}
// Custom Zod issues usually come from superRefine rules, not enum schemas.
// Avoid bundled channel schema lookup here: it can pull in runtime plugin
// metadata during validation error formatting and hang on some bootstrap paths.
return { values: [], incomplete: false, hasValues: false };
}
@ -438,7 +318,7 @@ export function validateConfigObject(
}
return {
ok: true,
config: applyModelDefaults(applyAgentDefaults(applySessionDefaults(result.config))),
config: materializeRuntimeConfig(result.config, "snapshot"),
};
}

View File

@ -24,6 +24,7 @@ const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
};
const MISSING_CONFIG_RETRY_DELAY_MS = 150;
const MISSING_CONFIG_MAX_RETRIES = 2;
const SELF_WRITE_WATCHER_SUPPRESSION_MS = 350;
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
if (prev === next) {
@ -96,7 +97,8 @@ export function startGatewayConfigReloader(opts: {
let restartQueued = false;
let missingConfigRetries = 0;
let pendingInProcessConfig: OpenClawConfig | null = null;
let suppressedWatchEventsRemaining = 0;
let lastAppliedWriteHash: string | null = null;
let ignoreWatcherEventsUntilMs = 0;
const scheduleAfter = (wait: number) => {
if (stopped) {
@ -211,6 +213,14 @@ export function startGatewayConfigReloader(opts: {
return;
}
const snapshot = await opts.readSnapshot();
if (
lastAppliedWriteHash &&
typeof snapshot.hash === "string" &&
snapshot.hash === lastAppliedWriteHash
) {
lastAppliedWriteHash = null;
return;
}
if (handleMissingSnapshot(snapshot)) {
return;
}
@ -236,8 +246,7 @@ export function startGatewayConfigReloader(opts: {
});
const scheduleFromWatcher = () => {
if (suppressedWatchEventsRemaining > 0) {
suppressedWatchEventsRemaining -= 1;
if (Date.now() < ignoreWatcherEventsUntilMs) {
return;
}
schedule();
@ -249,7 +258,11 @@ export function startGatewayConfigReloader(opts: {
return;
}
pendingInProcessConfig = event.runtimeConfig;
suppressedWatchEventsRemaining = 2;
lastAppliedWriteHash = event.persistedHash;
ignoreWatcherEventsUntilMs = Math.max(
ignoreWatcherEventsUntilMs,
event.writtenAtMs + SELF_WRITE_WATCHER_SUPPRESSION_MS,
);
scheduleAfter(0);
}) ?? (() => {});

View File

@ -3,7 +3,7 @@ import {
type ModelCatalogEntry,
resetModelCatalogCacheForTest,
} from "../agents/model-catalog.js";
import { loadConfig } from "../config/config.js";
import { getRuntimeConfig } from "../config/config.js";
export type GatewayModelChoice = ModelCatalogEntry;
@ -14,6 +14,8 @@ export function __resetModelCatalogCacheForTest() {
resetModelCatalogCacheForTest();
}
export async function loadGatewayModelCatalog(): Promise<GatewayModelChoice[]> {
return await loadModelCatalog({ config: loadConfig() });
export async function loadGatewayModelCatalog(params?: {
getConfig?: () => ReturnType<typeof getRuntimeConfig>;
}): Promise<GatewayModelChoice[]> {
return await loadModelCatalog({ config: (params?.getConfig ?? getRuntimeConfig)() });
}

View File

@ -13,6 +13,7 @@ import {
type ConfigFileSnapshot,
type OpenClawConfig,
applyConfigOverrides,
getRuntimeConfig,
isNixMode,
loadConfig,
migrateLegacyConfig,
@ -518,7 +519,7 @@ export async function startGatewayServer(
}
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();
startDiagnosticHeartbeat(undefined, { getConfig: getRuntimeConfig });
}
setGatewaySigusr1RestartPolicy({ allowExternal: isRestartEnabled(cfgAtStart) });
setPreRestartDeferralCheck(

View File

@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { loadConfig } from "../config/config.js";
import { getRuntimeConfig } from "../config/config.js";
import {
resolveGatewayLaunchAgentLabel,
resolveGatewaySystemdServiceName,
@ -477,7 +477,7 @@ export function scheduleGatewaySigusr1Restart(opts?: {
emitGatewayRestart();
return;
}
const cfg = loadConfig();
const cfg = getRuntimeConfig();
deferGatewayRestartUntilIdle({
getPendingCount: pendingCheck,
maxWaitMs: cfg.gateway?.reload?.deferralTimeoutMs,

View File

@ -1,4 +1,4 @@
import { loadConfig } from "../config/config.js";
import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
import {
@ -330,7 +330,10 @@ export function logActiveRuns() {
let heartbeatInterval: NodeJS.Timeout | null = null;
export function startDiagnosticHeartbeat(config?: OpenClawConfig) {
export function startDiagnosticHeartbeat(
config?: OpenClawConfig,
opts?: { getConfig?: () => OpenClawConfig },
) {
if (heartbeatInterval) {
return;
}
@ -338,7 +341,7 @@ export function startDiagnosticHeartbeat(config?: OpenClawConfig) {
let heartbeatConfig = config;
if (!heartbeatConfig) {
try {
heartbeatConfig = loadConfig();
heartbeatConfig = (opts?.getConfig ?? getRuntimeConfig)();
} catch {
heartbeatConfig = undefined;
}