Entrada de configuración openclaw.json
Responsabilidad
openclaw.json es el único archivo de configuración (config) con autoría humana en OpenClaw: puerto del gateway, provider de modelo, credenciales de canal, rutas de carga de skills, cron, política de memoria, approvals, hooks — todo se escribe en este único archivo JSON5. En runtime toda la configuración se lee de aquí, y tras expansión de include, sustitución de variables de entorno, validación por Zod schema e inyección de defaults, se convierte en el objeto OpenClawConfig en memoria.
No es solo un archivo «leer una vez y listo»: la CLI config set/patch/unset escribe de forma atómica, el snapshot runtime se compara con el contenido en disco para evitar condiciones de carrera, y ante problemas se puede restaurar desde el backup last-known-good. Por tanto esta entrada asume cinco funciones: leer, escribir, validar, respaldar y migrar.
Motivación de diseño
¿Por qué un único archivo en lugar de un directorio disperso? En los primeros días de OpenClaw, provider/channel/memory vivían en JSON separados, y el resultado era que cambiar un provider requería tocar tres archivos, y al actualizar un plugin de canal tocaba esparcir lógica de migración por todas partes. Al converger en un único openclaw.json, version stamp, backup y log de auditoría cuelgan todos del mismo archivo, y openclaw doctor solo escanea una ruta para hacer un check-up.
Pero un único archivo también tiende a inflarse, así que se soportan dos vías de escape:
- La directiva
$includesepara grandes bloques de config de provider/channel a subarchivos, dejando solo referencias en la raíz. - La sustitución de variables
${VAR}permite sacar los secrets del archivo y llevarlos a un gestor.
Estas dos características dejan que openclaw.json pueda entrar en un repo git (tras ofuscar secrets) y a la vez correr en despliegues tipo Docker/Tailscale que solo reciben variables de entorno.
Archivos clave
paths.ts CONFIG_FILENAME:25-26— constante de nombreopenclaw.json, con fallback legacyclawdbot.jsonal lado.resolveStateDir:58-87— resuelve el state dir,$OPENCLAW_STATE_DIRoverride, si no~/.openclaw, si no fallback legacy.clawdbot.resolveCanonicalConfigPath:152-161— ruta canónica$OPENCLAW_CONFIG_PATHo$stateDir/openclaw.json.resolveConfigPath:191-226— al leer de verdad, prioriza candidatos ya existentes (incluido legacy) y si no, vuelve al canónico.normalizeStateDirEnv:89-95— al arrancar expande~de$OPENCLAW_STATE_DIRy lo escribe de vuelta al env, primera acción de la secuencia de arranque del gateway.resolveIncludeRoots:117-143— whitelist$OPENCLAW_INCLUDE_ROOTS, permite que$includesalga del directorio de config.createConfigIO:1378-1434— factory de facade IO, encapsula leer, escribir, snapshot, backup, validar.loadConfigLocal:1654-1828— núcleo de la ruta de lectura síncrona: dotenv → leer archivo → JSON5 parse → expandir include → sustituir env → validar → materializar config runtime.writeConfigFileLocal:2269-2366— núcleo de la ruta de escritura: comparar snapshot, generar merge-patch, validar, escribir atómico a disco.writeConfigFile export:2826-2860— entrada pública de escritura, proyecta el snapshot runtime de vuelta al source antes de escribir.applyMergePatch:83— implementación de JSON merge patch, base del modo patch.applyConfigOverrides:93— overrides inyectados en runtime (tests, parámetros CLI temporales).env-substitution.ts— resolución de${VAR}, si falta la variable solo warn no crash.includes.ts— resolución de la directiva$include, con protección contra path traversal.defaults.ts apply*:109-529— inyección de defaultsapplyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaults, etc.runConfigPatch:2279-2309— entrada CLI deopenclaw config patch.mergeConfigValue:771-790— merge recursivo al aplicar patch, con ruta de deduplicación especial para arraysmodels[]de provider.
Flujo de datos
Al arrancar, createConfigIO primero fija las constantes de ruta y luego pasa por loadConfigLocal (loadConfigLocal:1654). Pasos clave de la ruta de lectura:
function loadConfigLocal(options: { skipSuspiciousRecovery?: boolean } = {}): OpenClawConfig {
try {
maybeLoadDotEnvForConfig(deps.env);
const envBeforeRead = snapshotEnv(deps.env);
if (!deps.fs.existsSync(configPath)) {
// ... carga shell env fallback y devuelve config vacía
return {};
}
const raw = deps.fs.readFileSync(configPath, "utf-8");
const parsed = deps.json5.parse(raw);
const readResolution = resolveConfigForRead(
resolveConfigIncludesForRead(parsed, configPath, deps),
deps.env,
deps.lowerPrecedenceEnv,
);
// ... shipped plugin install records migración, validate, materializeTres cosas a notar: maybeLoadDotEnvForConfig solo carga dotenv de verdad cuando env es process.env (un env inyectado en tests no se toca); envBeforeRead se usa al escribir de vuelta para detectar «si env cambió durante la lectura»; resolveConfigIncludesForRead lee los archivos destino de $include y luego se los pasa a resolveConfigForRead para sustitución de ${VAR}. El orden es clave: include antes que sustitución de env, así los campos traídos por include también pueden usar ${VAR}.
La ruta de escritura pasa por writeConfigFile (writeConfigFile:2826). Cuando hay un snapshot runtime, primero hace diff de la config runtime que da el caller y la proyecta de vuelta al source:
export async function writeConfigFile(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<ConfigWriteResult> {
options.assertConfigPathForWrite?.();
const io = createConfigIO({ /* ... */ });
assertConfigWriteAllowedInCurrentMode({ configPath: io.configPath });
let nextCfg = cfg;
const runtimeConfigSnapshot = getRuntimeConfigSnapshotState();
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState();
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
if (hadBothSnapshots) {
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
}Este paso garantiza que lo que el caller escribe de vuelta es «la authoring-state del usuario», no la forma contaminada por defaults runtime — si no, los defaults del schema se solidificarían en el archivo y no se podría revertir al cambiar de versión.
El modo patch CLI (runConfigPatch:2279) recibe un objeto JSON5 de patch y lo mergea recursivamente en la config existente:
function mergeConfigValue(existing: unknown, patch: unknown, path: PathSegment[]): unknown {
if (isProviderModelListPath(path) && Array.isArray(existing) && Array.isArray(patch)) {
return mergeModelArrays(existing, patch);
}
if (isPlainRecord(existing) && isPlainRecord(patch)) {
// Deep merge: objetos recursivo, no-objetos sobreescribeArrays como models[] van por mergeModelArrays, que deduplica por campo id y luego mergea elementos, en lugar de reemplazar — añadir un model a un provider no borra todos los models existentes.
Relación entre las tres rutas de lectura, escritura y patch:
Límites y fallos
- Path drift: al arrancar, el gateway debe llamar primero
pinRuntimePaths(pinRuntimePaths:236-245), si no,OPENCLAW_CONFIG_PATHsetteado después del import de módulos leería valores viejos, causando el split «leo de una ruta, escribo en otra». - Fallback legacy:
resolveConfigPathescanea primeroclawdbot.jsony.clawdbot/, si no existen va al nuevo path. Al actualizar un despliegue viejo con ambos directorios, gana el nuevo; pero si el usuario deja el archivo viejo en~/.openclaw/por error, el proceso nuevo también lo lee — el comando doctor escanea específicamente este split. $includepath traversal: por defecto solo puede referenciar archivos del mismo directorio o subdirectorios deopenclaw.json. Para cruzar directorios hay que settear explícitamente$OPENCLAW_INCLUDE_ROOTS, y cada root se expande con tilde + se deduplica + se rechazan paths no absolutos (include roots filter:128-141).${VAR}ausente: no lanza error, solo warn «feature using this value will be unavailable» (src/config/io.ts:1950-1956), el gateway entra modo degradado en lugar de rechazar el arranque. Pero si falta la apiKey del provider, fallará al hacer la primera petición.- Modo Nix read-only: con
OPENCLAW_NIX_MODE=1,assertConfigWriteAllowedInCurrentModerechaza toda escritura, la config se gestiona externamente vía Nix (resolveIsNixMode:16-18). - Escritura atómica:
replaceFileAtomicescribe a un archivo temporal y luego rename, permisos de directorio0o700y de archivo0o600, en Windows usa un fallback por copia. - Bloqueo de caída brusca de tamaño: antes de escribir compara
previousBytesvsnextBytes, si cae por debajo del 50% sin flagallowConfigSizeDrop, rechaza la escritura — evita que un patch borre de golpe todo un bloque de provider. - last-known-good: cada lectura exitosa actualiza la huella
lastKnownGood(hash + bytes + mtime + dev/ino); si la siguiente lectura detecta caída de size o el campo gateway mode borrado, restaura automáticamente desde.baky deja una línea de log de auditoría con eventoconfig.observe. - Aviso de future version: cuando
meta.lastTouchedVersiondel archivo es más nuevo que el binario actual, warn al usuario sugiriendo que PATH podría apuntar a una versión vieja de openclaw — evita «una config escrita por versión nueva se corrompe al leerla con versión vieja».
Resumen
openclaw.json es una entrada única, pero detrás hay un ciclo de vida completo de lectura-validación-materialización-escritura-auditoría. La ruta de lectura expande include y env capa por capa, la ruta de escritura proyecta la forma runtime de vuelta al source, y el modo patch ofrece al CLI edición incremental estructurada. Cómo el schema define la forma legal de cada campo se cubre en Sistema de schemas Zod; los campos concretos del provider (baseUrl/api/auth/apiKey/models[]) en Providers; la config del lado canal en Configuración de canal; cómo el proceso residente usa la config leída en Daemon.
Referencias oficiales: Documentación de Configuration · README.