Entrée de configuration openclaw.json
Responsabilités
openclaw.json est l'unique fichier de configuration auteur (config) d'OpenClaw: port de la passerelle, provider de modèle, credentials des canaux, chemins de chargement des compétences, cron, stratégie de mémoire, approvals, hooks — tout est écrit dans ce seul fichier JSON5. Au runtime, toute config est lue depuis ce fichier, puis passe par expansion include, substitution de variables d'environnement, validation Zod schema, injection de valeurs par défaut, pour devenir l'objet OpenClawConfig en mémoire.
Ce n'est pas juste « lire une fois et c'est fini »: les config set/patch/unset du CLI écrivent atomiquement en retour; le snapshot runtime (runtime snapshot) est comparé au contenu disque pour empêcher les race conditions; en cas de problème, un backup last-known-good permet de rouler. Cette entrée assume donc cinq choses: lecture, écriture, validation, backup, migration.
Motivation de conception
Pourquoi un seul fichier plutôt qu'un répertoire éclaté? Aux débuts, OpenClaw mettait provider, channel, memory dans des JSON séparés, ce qui faisait que modifier un provider imposait de toucher trois fichiers, et les montées de version de plugin de canal éparpillaient la logique de migration. En convergeant vers un seul openclaw.json, version, backup et audit log sont tous accrochés au même fichier; openclaw doctor ne scanne qu'un seul chemin pour faire un bilan.
Mais un seul fichier devient gros, d'où deux échappatoires:
$includepousse de grands blocs provider/channel dans des sous-fichiers, le fichier racine ne gardant que la référence;${VAR}substitution de variables d'environnement pour sortir les secrets du fichier.
Ces deux features permettent à openclaw.json d'entrer dans un dépôt git (après脱敏), tout en s'exécutant dans des déploiements Docker/Tailscale qui ne fournissent que des variables d'environnement.
Fichiers clés
paths.ts CONFIG_FILENAME:25-26— Constante du nom de fichieropenclaw.json, avec repli legacyclawdbot.jsonà côté.resolveStateDir:58-87— Résout le state dir,$OPENCLAW_STATE_DIRoverride, sinon~/.openclaw, sinon repli legacy.clawdbot.resolveCanonicalConfigPath:152-161— Chemin canonical$OPENCLAW_CONFIG_PATHou$stateDir/openclaw.json.resolveConfigPath:191-226— À la lecture, privilégie le fichier candidat existant (incluant legacy), sinon repli canonical.normalizeStateDirEnv:89-95— Au démarrage,_expand~dans$OPENCLAW_STATE_DIRet réécrit l'env; première chose de la séquence de démarrage de la passerelle.resolveIncludeRoots:117-143— Allowlist$OPENCLAW_INCLUDE_ROOTS, autorise$includeà sortir du répertoire de config.createConfigIO:1378-1434— Factory de la facade IO, encapsule lecture/écriture/snapshot/backup/validation.loadConfigLocal:1654-1828— Cœur synchrone du chemin de lecture: dotenv → lire fichier → JSON5 parse → expansion include → substitution env → validation → matérialisation runtime config.writeConfigFileLocal:2269-2366— Cœur du chemin d'écriture: comparaison snapshot, merge-patch, validation, écriture atomique.writeConfigFile export:2826-2860— Entrée d'écriture publique, projette le runtime snapshot vers la forme source avant d'écrire.applyMergePatch:83— Implémentation JSON merge patch, base du mode patch.applyConfigOverrides:93— Overrides runtime injectés (tests, paramètres temporaires CLI).env-substitution.ts— Résolution${VAR}, variable manquante n'émet qu'un warn sans crasher.includes.ts— Résolution de la directive$include, avec protection contre le path traversal.defaults.ts apply*:109-529—applyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaultsetc., injection des valeurs par défaut.runConfigPatch:2279-2309— Entrée CLIopenclaw config patch.mergeConfigValue:771-790— Merge récursif lors d'un patch, chemin spécial de dédoublonnage pour le tableaumodels[]du provider.
Flux de données
Au démarrage, createConfigIO épingle d'abord les constantes de chemin, puis passe par loadConfigLocal (loadConfigLocal:1654). Les étapes clés du chemin de lecture:
function loadConfigLocal(options: { skipSuspiciousRecovery?: boolean } = {}): OpenClawConfig {
try {
maybeLoadDotEnvForConfig(deps.env);
const envBeforeRead = snapshotEnv(deps.env);
if (!deps.fs.existsSync(configPath)) {
// ... charge le shell env fallback puis renvoie une config vide
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 migration, validate, materializeTrois choses à noter: maybeLoadDotEnvForConfig ne charge dotenv que si env est égal à process.env (l'env injecté en tests n'est pas touché); envBeforeRead sert à détecter à l'écriture « si l'env a été modifié pendant la lecture »; resolveConfigIncludesForRead lit d'abord les fichiers $include cibles, puis resolveConfigForRead procède à la substitution ${VAR}. L'ordre est crucial: include avant substitution env, ainsi les champs importés par include peuvent aussi utiliser ${VAR}.
Le chemin d'écriture passe par writeConfigFile (writeConfigFile:2826). Quand il existe un snapshot runtime, il convertit d'abord la config runtime fournie par le caller en patch, puis la projette vers la forme 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));
}Cette étape garantit que ce que le caller réécrit est « la forme auteur utilisateur », et non la forme polluée par les valeurs par défaut runtime — sinon les defaults du schema seraient figés dans le fichier, impossibles à revenir en arrière lors d'un changement de version.
Le mode patch CLI (runConfigPatch:2279) reçoit un objet JSON5 patch et le fusionne récursivement dans la config existante:
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: récursion sur les objets, écrasement direct sur les non-objetsLe tableau models[] passe par mergeModelArrays, qui dédoublonne par champ id puis fusionne les éléments, plutôt qu'un remplacement simple — ajouter un modèle à un provider n'écrase pas tous les modèles existants.
Relation entre les trois chemins lecture, écriture, patch:
Limites et modes d'échec
- Dérive de chemin: la passerelle au démarrage doit d'abord appeler
pinRuntimePaths(pinRuntimePaths:236-245), sinonOPENCLAW_CONFIG_PATHdéfini après l'import du module lira une valeur périmée, provoquant un déchirement « lire un chemin, écrire un autre ». - Repli legacy:
resolveConfigPathscanne d'abordclawdbot.json,.clawdbot/, puis only le nouveau chemin. Lors d'un upgrade, si les deux répertoires existent, le nouveau est privilégié; mais l'utilisateur laissant l'ancien dans~/.openclaw/, le nouveau process le lira aussi — la commande doctor scanne spécifiquement ce schizo. $includehors périmètre: par défaut, ne peut référencer que des fichiers du même répertoire queopenclaw.jsonou de ses sous-répertoires. Pour sortir du répertoire, il faut explicitement poser$OPENCLAW_INCLUDE_ROOTS, chaque racine est tilde-expand + dédoublonnée + rejetée si non absolue (include roots filter:128-141).${VAR}manquante: ne jette pas, n'émet qu'un warn « feature using this value will be unavailable » (src/config/io.ts:1950-1956), la passerelle entre en mode dégradé plutôt que de refuser de démarrer. Mais une apiKey provider manquante jette à la première vraie requête.- Nix mode en lecture seule:
OPENCLAW_NIX_MODE=1,assertConfigWriteAllowedInCurrentModerefuse toutes les écritures, la config est gérée par Nix externe (resolveIsNixMode:16-18). - Écriture atomique:
replaceFileAtomicécrit un fichier temporaire puis rename, permission répertoire0o700, permission fichier0o600, Windows passe par copy fallback. - Blocage si chute brutale de taille: avant écriture, compare
previousBytesvsnextBytes; si chute à 50% sans flagallowConfigSizeDrop, refuse d'écrire — pour empêcher qu'un patch ne vire tout un bloc provider. - last-known-good: chaque lecture réussie met à jour l'empreinte
lastKnownGood(hash + bytes + mtime + dev/ino); si la lecture suivante détecte une chute de size ou que le champ gateway mode est effacé, restaure automatiquement depuis.bak, audit log enregistre un événementconfig.observe. - Avertissement future version: quand le fichier
meta.lastTouchedVersionest plus récent que le binaire courant, warn suggère que le PATH pointe peut-être vers une vieille version openclaw — pour éviter « config écrite par nouvelle version lue par ancienne version ».
Résumé
openclaw.json est une entrée unique, mais背后 c'est tout un cycle de vie lecture-validation-matérialisation-écriture-audit. Le chemin lecture déplie include et env, le chemin écriture projette la forme runtime inverse vers la forme source, le mode patch offre au CLI une édition incrémentale structurée. Comment le schema définit la forme légale de chaque champ, voir validation runtime Zod Schema; les champs spécifiques du provider (baseUrl/api/auth/apiKey/models[]) sont détaillés dans couche capacités: modèle Provider; la config côté canaux dans canaux: configuration; comment le process daemon consomme la config lue dans déploiement: daemon.
Pour comparer avec la documentation officielle: Configuration docs · README.