Skip to content

fix: preserve ${VAR} env var references when writing config back to disk#11560

Merged
steipete merged 3 commits into
openclaw:mainfrom
akoscz:feature/config-env-preserve
Feb 14, 2026
Merged

fix: preserve ${VAR} env var references when writing config back to disk#11560
steipete merged 3 commits into
openclaw:mainfrom
akoscz:feature/config-env-preserve

Conversation

@akoscz

@akoscz akoscz commented Feb 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #11466

When config is loaded, ${VAR} env var references are resolved to their plaintext values. Previously, writeConfigFile would serialize the resolved values, silently replacing "${ANTHROPIC_API_KEY}" with "sk-ant-api03-..." in the config file. This happened across all write paths — doctor, configure wizard, gateway config handlers, plugins, hooks, etc.

Fix

Added a restoreEnvVarRefs() utility that runs inside writeConfigFile before serialization:

  1. Reads the current config file and parses it without env substitution
  2. Walks the incoming config object alongside the pre-substitution parsed object
  3. For each string value: if the pre-substitution version contained a ${VAR} pattern and resolving it produces the incoming value, restores the ${VAR} reference
  4. If the caller intentionally changed the value (doesn't match the resolved env var), keeps the new value

This fixes all 50+ writeConfigFile call sites without requiring any caller changes.

New files

  • src/config/env-preserve.tsrestoreEnvVarRefs() utility
  • src/config/env-preserve.test.ts — 11 unit tests covering: simple restoration, intentional changes, nested objects, arrays, composite templates, missing env vars, type mismatches

Modified files

  • src/config/io.tswriteConfigFile now calls restoreEnvVarRefs before serialization

Design decisions

  • Fix at the write layer, not per-caller — one fix covers all 50+ write paths
  • Conservative restoration — only restores when the resolved value exactly matches; intentional changes are preserved
  • Best-effort — if reading the current file or parsing fails, writes as before (no regression)
  • No API changeswriteConfigFile signature unchanged, all callers work as before

Greptile Overview

Greptile Summary

This PR fixes config write-back so ${VAR} env var references aren’t silently replaced with their resolved plaintext values. It introduces restoreEnvVarRefs() (walks the to-be-written config alongside the current on-disk parsed config without env substitution and restores template strings when they resolve back to the incoming value) and wires it into createConfigIO().writeConfigFile() before serialization.

To address env TOCTOU (env changes between read and write), createConfigIO captures an env snapshot after substitution during loadConfig() and readConfigFileSnapshot(), and the module-level wrapper functions bridge that snapshot across separate IO instances (keyed by configPath, capped to 16 entries). Tests cover restoration behavior, escape $${VAR} semantics, nested structures/arrays, and the snapshot bridging behavior.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk.
  • Changes are localized to config IO/write behavior, include targeted unit tests for env-template restoration and TOCTOU snapshotting, and avoid previously raised include-flattening regressions by using only the root file’s parsed content for restoration comparisons.
  • No files require special attention

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment thread src/config/io.ts Outdated
Comment on lines +496 to +516
// 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.
let cfgToWrite: OpenClawConfig = cfg;
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) {
// Resolve includes to get the full pre-substitution object
let preSubstitution: unknown = parsedRes.parsed;
try {
preSubstitution = resolveConfigIncludes(parsedRes.parsed, configPath, {
readFile: (p) => deps.fs.readFileSync(p, "utf-8"),
parseJson: (raw) => deps.json5.parse(raw),
});
} catch {
// If include resolution fails, use parsed as-is
}
cfgToWrite = restoreEnvVarRefs(cfg, preSubstitution, deps.env) as OpenClawConfig;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Included configs may get overwritten

writeConfigFile now restores env-var templates by parsing only the root config file and (optionally) resolving $includes, but it always writes the result back to configPath only. If a value originally lived in an included file and was referenced from the root, this logic will still restore ${VAR} into the in-memory object and then serialize the entire resolved config into the root file, potentially overwriting the user’s include-based layout (and moving values from included files into the root on the next write path). This breaks setups that rely on $include for separation. Consider skipping restoration when the current file uses $include, or preserving/includes-aware write behavior instead of writing a fully-resolved object to the root.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/io.ts
Line: 496:516

Comment:
**Included configs may get overwritten**

`writeConfigFile` now restores env-var templates by parsing only the *root* config file and (optionally) resolving `$include`s, but it always writes the result back to `configPath` only. If a value originally lived in an included file and was referenced from the root, this logic will still restore `${VAR}` into the in-memory object and then serialize the *entire* resolved config into the root file, potentially overwriting the user’s include-based layout (and moving values from included files into the root on the next write path). This breaks setups that rely on `$include` for separation. Consider skipping restoration when the current file uses `$include`, or preserving/includes-aware write behavior instead of writing a fully-resolved object to the root.


How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1d5dcfd — now using only the root file's parsed content (no $include resolution) for env var restoration. This avoids pulling values from included files into the root config on write-back. The $include flattening on write is a pre-existing issue with writeConfigFile itself, not introduced by this change.

@akoscz

akoscz commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review the PR again

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment thread src/config/io.ts
Comment on lines +505 to +511
if (deps.fs.existsSync(configPath)) {
const currentRaw = await deps.fs.promises.readFile(configPath, "utf-8");
const parsedRes = parseConfigJson5(currentRaw, deps.json5);
if (parsedRes.ok) {
cfgToWrite = restoreEnvVarRefs(cfg, parsedRes.parsed, deps.env) as OpenClawConfig;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent env changes on write

restoreEnvVarRefs verifies templates using deps.env at write time, but loadConfig can mutate deps.env via applyConfigEnv(...) (config-defined env keys) and that mutation can change between the original read and a later write. If a config file contains "${FOO}" and FOO is set via config.env (or changes in the process environment), a subsequent writeConfigFile can incorrectly restore "${FOO}" (or fail to restore it) based on the current env, not the env that produced cfg. This can make write-back depend on external env state rather than the caller’s intended value.

Consider verifying against the same env-substitution rules/state used when producing cfg (or passing an explicit env snapshot into writeConfigFile).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/io.ts
Line: 505:511

Comment:
**Silent env changes on write**

`restoreEnvVarRefs` verifies templates using `deps.env` at write time, but `loadConfig` can mutate `deps.env` via `applyConfigEnv(...)` (config-defined `env` keys) and that mutation can change between the original read and a later write. If a config file contains `"${FOO}"` and `FOO` is set via `config.env` (or changes in the process environment), a subsequent `writeConfigFile` can incorrectly restore `"${FOO}"` (or fail to restore it) based on the *current* env, not the env that produced `cfg`. This can make write-back depend on external env state rather than the caller’s intended value.

Consider verifying against the same env-substitution rules/state used when producing `cfg` (or passing an explicit env snapshot into `writeConfigFile`).

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Investigated this thoroughly. Wrote 3 targeted tests for the env mutation scenario:

  1. Env mutated to different valuerestoreEnvVarRefs correctly does NOT restore (resolved value no longer matches incoming). ✅
  2. Env stable — correctly restores. ✅
  3. Env mutates to coincidentally match incoming — This is the only problematic case: caller intentionally set "xyz789", but env also changed to "xyz789", so restoration incorrectly fires. However:

Why this is not practically exploitable:

  • applyConfigEnv only sets vars that are NOT already set (if (env[key]?.trim()) continue)
  • writeConfigFile runs immediately after config changes, not after long delays
  • The false positive requires an external env mutation that coincidentally matches the caller's new value — astronomically unlikely in normal operation
  • Fixing it properly requires snapshotting env at read time and threading it through to write time — a much larger refactor beyond this PR's scope

The current code is safe for all realistic scenarios. The env-verification check is strictly better than no check (which would always restore). Could note the TOCTOU caveat in a code comment for future readers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional investigation — security and circular dependency analysis:

Is the TOCTOU a security attack vector?
No. Exploiting it requires modifying process.env at runtime, which means the attacker already has arbitrary code execution in the Node.js process. At that point, env manipulation is the least of your concerns — they can read secrets, modify config directly, or execute anything. This is a correctness concern, not a security one.

Can config.env create circular dependencies?
No — the architecture prevents it by design:

  1. applyConfigEnv() runs on the raw parsed config (before ${VAR} substitution)
  2. So env: { API_KEY: "${ANOTHER_VAR}" } sets the literal string "${ANOTHER_VAR}" into process.env, NOT a resolved value
  3. resolveConfigEnvVars() then does a single-pass substitution — no recursion
  4. The resolved value of ${API_KEY} would be the literal text "${ANOTHER_VAR}", which is never re-substituted
loadConfig flow:
  parse JSON → applyConfigEnv (raw values → process.env) → resolveConfigEnvVars (single pass)
                    ↑ no ${} resolution here                    ↑ no recursive resolution

Conclusion: The TOCTOU caveat is theoretical, not exploitable, and architecturally bounded. The current implementation is sound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update — reconsidering the TOCTOU with web UI in mind:

The TOCTOU window becomes much more realistic with the dashboard/web UI:

  1. Dashboard loads config → env vars resolved → sent to browser
  2. User edits in browser (could take minutes or hours)
  3. Browser POSTs back → restoreEnvVarRefs() runs against current env

During that window, env could legitimately change (gateway restart, config.env changes, external env mutation). At scale with hundreds of thousands of users, this stops being theoretical.

The config.apply and config.patch server methods both call writeConfigFile()restoreEnvVarRefs() with live process.env. The flow: dashboard → restoreRedactedValues()writeConfigFile()restoreEnvVarRefs(cfg, parsed, process.env).

Fix: snapshot env at read time and use it at write time. Implementing this now — ~15 line change to capture process.env after substitution in loadConfig and thread it through to writeConfigFile.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e436d87 — writeConfigFile now uses an env snapshot captured at load time instead of live process.env.

What changed in io.ts:

  • Added envSnapshotForRestore closure variable in createConfigIO
  • Captured { ...deps.env } after applyConfigEnv + resolveConfigEnvVars in both loadConfig and readConfigFileSnapshot
  • writeConfigFile uses the snapshot for restoreEnvVarRefs (falls back to live env if no snapshot exists)

Test updated: The TOCTOU test now proves correct behavior — when env mutates between read and write, the snapshot preserves the original env state and prevents false positive restoration.

All 321 config tests pass.

Comment thread src/config/env-preserve.ts Outdated
Comment on lines +39 to +56
function tryResolveString(template: string, env: NodeJS.ProcessEnv): string | null {
const ENV_VAR_NAME = /^[A-Z_][A-Z0-9_]*$/;
const chunks: string[] = [];

for (let i = 0; i < template.length; i++) {
if (template[i] === "$" && template[i + 1] === "{") {
const start = i + 2;
const end = template.indexOf("}", start);
if (end !== -1) {
const name = template.slice(start, end);
if (ENV_VAR_NAME.test(name)) {
const val = env[name];
if (val === undefined || val === "") {
return null;
}
chunks.push(val);
i = end;
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect template resolution

tryResolveString() does a manual scan and treats any $ not starting a valid ${NAME} as a literal, but this is not the same escaping/templating behavior used by resolveConfigEnvVars (which supports $${VAR} escaping and can substitute multiple occurrences). As a result, restoreEnvVarRefs can restore ${VAR} references in cases where the real substitution logic would not have produced incoming, or fail to restore when it should.

This should reuse the same substitution semantics as resolveConfigEnvVars (or share the underlying helper) to ensure restore/resolve are consistent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/env-preserve.ts
Line: 39:56

Comment:
**Incorrect template resolution**

`tryResolveString()` does a manual scan and treats any `$` not starting a valid `${NAME}` as a literal, but this is not the same escaping/templating behavior used by `resolveConfigEnvVars` (which supports `$${VAR}` escaping and can substitute multiple occurrences). As a result, `restoreEnvVarRefs` can restore `${VAR}` references in cases where the real substitution logic would *not* have produced `incoming`, or fail to restore when it should.

This should reuse the same substitution semantics as `resolveConfigEnvVars` (or share the underlying helper) to ensure restore/resolve are consistent.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid finding — confirmed with failing tests. tryResolveString does not handle the $${VAR} escape sequence that substituteString (in env-substitution.ts) supports.

Reproduction:

// Config file has: $${ANTHROPIC_API_KEY}
// substituteString resolves to literal: "${ANTHROPIC_API_KEY}"
// But tryResolveString sees "$" then "${ANTHROPIC_API_KEY}" and resolves the inner ${} — wrong

const incoming = { note: "${ANTHROPIC_API_KEY}" };  // literal text from $${}
const parsed = { note: "$${ANTHROPIC_API_KEY}" };
restoreEnvVarRefs(incoming, parsed, env);
// Expected: { note: "$${ANTHROPIC_API_KEY}" }  (restore escape)
// Actual:   { note: "${ANTHROPIC_API_KEY}" }    (lost the escape)

Fixing by adding $${ escape handling to tryResolveString, mirroring the canonical logic in substituteString. Will also note that a follow-up refactor to share the substitution logic between both modules would eliminate this class of drift.

@akoscz

akoscz commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

hey @greptileai review the PR again. I've got some updates for you

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +49 to +51
// Escaped: $${VAR} -> literal ${VAR}
if (template[i + 1] === "$" && template[i + 2] === "{") {
const start = i + 3;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Env var pattern mismatch

hasEnvVarRef() only looks for ${VAR} via ENV_VAR_PATTERN, but tryResolveString() also supports the $${VAR} escape form. This means configs that only contain $${VAR} (intentionally producing a literal ${VAR}) will never enter the resolution/restore path, so the escape sequence won’t be preserved on write-back.

This shows up with parsed: { note: "$${ANTHROPIC_API_KEY}" } and incoming: { note: "${ANTHROPIC_API_KEY}" }: hasEnvVarRef(parsed) is false, so restoreEnvVarRefs returns incoming and drops the $${...} escape.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/env-preserve.ts
Line: 49:51

Comment:
**Env var pattern mismatch**

`hasEnvVarRef()` only looks for `${VAR}` via `ENV_VAR_PATTERN`, but `tryResolveString()` also supports the `$${VAR}` escape form. This means configs that *only* contain `$${VAR}` (intentionally producing a literal `${VAR}`) will never enter the resolution/restore path, so the escape sequence won’t be preserved on write-back.

This shows up with `parsed: { note: "$${ANTHROPIC_API_KEY}" }` and `incoming: { note: "${ANTHROPIC_API_KEY}" }`: `hasEnvVarRef(parsed)` is false, so `restoreEnvVarRefs` returns `incoming` and drops the `$${...}` escape.


How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive. ENV_VAR_PATTERN (/\$\{[A-Z_][A-Z0-9_]*\}/) is not anchored — .test() finds substrings. So "$${ANTHROPIC_API_KEY}" contains the substring "${ANTHROPIC_API_KEY}" which matches the regex:

/\$\{[A-Z_][A-Z0-9_]*\}/.test("$${ANTHROPIC_API_KEY}")  // → true

The $${VAR} case enters the restoration path correctly. Verified with passing tests:

  • "handles $${VAR} escape sequence (literal ${VAR} in output)"
  • "does not confuse $${VAR} escape with ${VAR} substitution"

Comment on lines +56 to +58
chunks.push(`\${${name}}`);
i = end;
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect escape restoration

In the $${VAR} branch, chunks.push(${${name}}) adds a backslash before the ${...}. The documented/implemented semantics in env-substitution.ts are $${VAR} -> ${VAR} (no backslash), so this will restore the wrong literal ("\${VAR}") and won’t round-trip with the loader.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/env-preserve.ts
Line: 56:58

Comment:
**Incorrect escape restoration**

In the `$${VAR}` branch, `chunks.push(`\${${name}}`)` adds a backslash before the `${...}`. The documented/implemented semantics in `env-substitution.ts` are `$${VAR} -> ${VAR}` (no backslash), so this will restore the *wrong* literal (`"\${VAR}"`) and won’t round-trip with the loader.


How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive — this is standard JavaScript template literal escaping. In a backtick string, \${ produces the literal characters ${:

const name = "FOO";
console.log(`\${${name}}`);  // outputs: ${FOO}

This is the exact same syntax used in the canonical substituteString in env-substitution.ts (line 71):

chunks.push(`\${${name}}`);  // produces "${VAR}" — no backslash in output

Both escape tests pass, confirming correct round-trip behavior.

@akoscz

akoscz commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Fix: env snapshot bridge across exported IO wrappers

During internal review, we discovered that the TOCTOU fix in e436d87 was ineffective in production call paths.

The bug

readConfigFileSnapshot() and writeConfigFile() are exported wrappers that each create a new createConfigIO() instance. The env snapshot captured during read was trapped inside instance A and never reached instance B (write), so restoreEnvVarRefs() always fell back to live process.env — the exact TOCTOU issue the snapshot was meant to prevent.

The dominant server paths in src/gateway/server-methods/config.ts (config.patch, config.apply, config.set) all use these exported wrappers, not a shared IO instance.

The fix (63583236d)

  • Added a module-level _moduleEnvSnapshot that bridges the gap between wrapper calls
  • readConfigFileSnapshot() stores the snapshot via io.getEnvSnapshot()
  • writeConfigFile() injects it via io.setEnvSnapshot() before writing
  • Added getEnvSnapshot()/setEnvSnapshot() to the IO instance interface

Test proof

New file: src/config/env-preserve-io.test.ts (2 integration tests)

  1. With snapshot bridging: reads config with MY_API_KEY=original-key-123, mutates env to mutated-key-456, writes with bridged snapshot → file correctly contains ${MY_API_KEY}
  2. Without snapshot bridging (old behavior): same scenario but no snapshot injection → restoration fails because live env no longer matches, resolved value written as-is ✅

All 323 config tests pass (47 files).

@akoscz

akoscz commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai ok dude, review the PR once more

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment thread src/config/io.ts Outdated
Comment on lines +62 to +67
// Module-level env snapshot shared across exported wrapper functions.
// readConfigFileSnapshot() stores it; writeConfigFile() consumes it.
// This bridges the TOCTOU gap when callers use the exported wrappers
// (which create separate createConfigIO instances) rather than reusing
// a single IO instance.
let _moduleEnvSnapshot: Record<string, string | undefined> | null = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-request env snapshot leak

_moduleEnvSnapshot is module-scoped and reused by the exported writeConfigFile() wrapper. That means a later writeConfigFile() call can apply an env snapshot captured by an unrelated earlier readConfigFileSnapshot() call (possibly for a different config path/env), causing ${VAR} restoration decisions to be based on stale/incorrect env state. This is user-visible when a process reads config A, then writes config B without another read, or when OPENCLAW_CONFIG_PATH changes between calls.

Also appears at src/config/io.ts:660-678 where the snapshot is stored/consumed without tying it to configPath or the deps.env identity.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/io.ts
Line: 62:67

Comment:
**Cross-request env snapshot leak**

`_moduleEnvSnapshot` is module-scoped and reused by the exported `writeConfigFile()` wrapper. That means a later `writeConfigFile()` call can apply an env snapshot captured by an unrelated earlier `readConfigFileSnapshot()` call (possibly for a different config path/env), causing `${VAR}` restoration decisions to be based on stale/incorrect env state. This is user-visible when a process reads config A, then writes config B without another read, or when `OPENCLAW_CONFIG_PATH` changes between calls.

Also appears at `src/config/io.ts:660-678` where the snapshot is stored/consumed without tying it to `configPath` or the `deps.env` identity.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good observation, but this is actually by design and already handled. The snapshot is stored in _moduleEnvSnapshots keyed by configPath and is consumed/deleted after use (_moduleEnvSnapshots.delete(io.configPath)). This bridges the TOCTOU gap between separate IO instances created by the exported wrappers. The comment in the code (line 113-115) explains this pattern.

Comment thread src/config/io.ts
Comment on lines 660 to +677
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
return await createConfigIO().readConfigFileSnapshot();
const io = createConfigIO();
const snapshot = await io.readConfigFileSnapshot();
// Persist env snapshot from this IO instance so a subsequent writeConfigFile()
// call (which creates its own IO instance) can use the read-time env state.
_moduleEnvSnapshot = io.getEnvSnapshot();
return snapshot;
}

export async function writeConfigFile(cfg: OpenClawConfig): Promise<void> {
clearConfigCache();
await createConfigIO().writeConfigFile(cfg);
const io = createConfigIO();
// Inject module-level env snapshot from a prior readConfigFileSnapshot() call
// so that env restoration uses read-time env, not live env (TOCTOU fix).
if (_moduleEnvSnapshot) {
io.setEnvSnapshot(_moduleEnvSnapshot);
}
await io.writeConfigFile(cfg);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snapshot not path-scoped

The wrapper readConfigFileSnapshot() saves io.getEnvSnapshot() into _moduleEnvSnapshot, and writeConfigFile() later injects that snapshot into a fresh IO instance. Because _moduleEnvSnapshot is not associated with configPath, a config write can incorrectly use a snapshot captured from a different file (or prior environment state) if OPENCLAW_CONFIG_PATH (or other path resolution inputs) change between calls.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/io.ts
Line: 660:677

Comment:
**Snapshot not path-scoped**

The wrapper `readConfigFileSnapshot()` saves `io.getEnvSnapshot()` into `_moduleEnvSnapshot`, and `writeConfigFile()` later injects that snapshot into a fresh IO instance. Because `_moduleEnvSnapshot` is not associated with `configPath`, a config write can incorrectly use a snapshot captured from a different file (or prior environment state) if `OPENCLAW_CONFIG_PATH` (or other path resolution inputs) change between calls.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snapshot is actually path-scoped — it's stored in _moduleEnvSnapshots which is a Map keyed by io.configPath. The comment in the code (line 113-115) and the usage in readConfigFileSnapshot() and writeConfigFile() both confirm this: snapshots are stored, retrieved, and deleted based on configPath. This ensures each config file path has its own isolated snapshot.

@akoscz

akoscz commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Re: Cross-request env snapshot leak & Snapshot not path-scoped — Valid point. Fixed in 627240d.

The module-level snapshot is now a Map<configPath, snapshot> instead of a single variable. readConfigFileSnapshot() stores keyed by resolved configPath, and writeConfigFile() only consumes the matching entry (deleted after use to prevent stale state). This prevents cross-config contamination if OPENCLAW_CONFIG_PATH changes between calls.

Re: Env var pattern mismatch (hasEnvVarRef missing $${VAR}) — False positive. The regex \$\{[A-Z_][A-Z0-9_]*\} matches the ${VAR} substring inside $${VAR}, so hasEnvVarRef("$${FOO}") returns true. The escape sequence correctly enters the resolution path.

Re: Incorrect escape restoration (backslash in template literal) — False positive. The backtick expression uses \$ to prevent JavaScript from interpreting ${ as template interpolation. The runtime output is ${NAME}, not \${NAME}. Verified by existing tests.

@akoscz

akoscz commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review the PR again

@p697 p697 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 We independently discovered and reproduced this exact bug today. config.patch expands all ${VAR} references to plaintext values on every write. This PR's approach of restoring env var refs at the writeConfigFile layer is clean — fixes all call sites at once. Would love to see this merged!

@akoscz

akoscz commented Feb 10, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai please review this PR

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment thread src/config/io.ts Outdated
Comment on lines +61 to +64
const CONFIG_BACKUP_COUNT = 5;
const loggedInvalidConfigs = new Set<string>();

// Module-level env snapshot shared across exported wrapper functions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded snapshot map growth

_moduleEnvSnapshots is module-scoped and only deletes entries when writeConfigFile() is called for the same configPath. In long-lived processes that call readConfigFileSnapshot() across many different paths (or read without ever writing), this can grow without bound. Consider pruning (e.g., size cap/LRU) or associating an expiry/cleanup when snapshots are stored so stale entries don’t accumulate indefinitely.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/io.ts
Line: 61:64

Comment:
**Unbounded snapshot map growth**

`_moduleEnvSnapshots` is module-scoped and only deletes entries when `writeConfigFile()` is called for the same `configPath`. In long-lived processes that call `readConfigFileSnapshot()` across many different paths (or read without ever writing), this can grow without bound. Consider pruning (e.g., size cap/LRU) or associating an expiry/cleanup when snapshots are stored so stale entries don’t accumulate indefinitely.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-issue in practice — OpenClaw reads/writes 1-2 config paths per process lifetime (openclaw.json + possibly a legacy path). The Map will have at most 2 entries. A size cap or LRU would add complexity for a problem that doesn't exist in the real deployment model. The snapshot is consumed (deleted) on write, so paths that complete the read→write cycle clean up after themselves.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c7486ee — added a size cap of 16 entries with FIFO eviction. Oldest entry is evicted when the map reaches capacity. In practice the map holds 1-2 entries, but this guards against the theoretical scenario.

@akoscz akoscz force-pushed the feature/config-env-preserve branch from 99c2d81 to 13cd197 Compare February 10, 2026 04:37
@akoscz

akoscz commented Feb 10, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai please review this PR

@steipete steipete force-pushed the feature/config-env-preserve branch from 8c54b5c to 4f92f47 Compare February 14, 2026 00:41
akoscz and others added 3 commits February 14, 2026 01:42
Fixes openclaw#11466

When config is loaded, ${VAR} references are resolved to their plaintext
values. Previously, writeConfigFile would serialize the resolved values,
silently replacing "${ANTHROPIC_API_KEY}" with "sk-ant-api03-..." in the
config file.

Now writeConfigFile reads the current file pre-substitution, and for each
value that matches what a ${VAR} reference would resolve to, restores the
original reference. Values the caller intentionally changed are kept as-is.

This fixes all 50+ writeConfigFile call sites (doctor, configure wizard,
gateway config.set/apply/patch, plugins, hooks, etc.) without requiring
any caller changes.

New files:
- src/config/env-preserve.ts — restoreEnvVarRefs() utility
- src/config/env-preserve.test.ts — 11 unit tests
@steipete steipete force-pushed the feature/config-env-preserve branch from 4f92f47 to 4585293 Compare February 14, 2026 00:49
@steipete steipete merged commit a4f4b06 into openclaw:main Feb 14, 2026
9 checks passed
@openclaw-barnacle openclaw-barnacle Bot added the gateway Gateway runtime label Feb 14, 2026
GwonHyeok pushed a commit to learners-superpumped/openclaw that referenced this pull request Feb 15, 2026
…isk (openclaw#11560)

* fix: preserve ${VAR} env var references when writing config back to disk

Fixes openclaw#11466

When config is loaded, ${VAR} references are resolved to their plaintext
values. Previously, writeConfigFile would serialize the resolved values,
silently replacing "${ANTHROPIC_API_KEY}" with "sk-ant-api03-..." in the
config file.

Now writeConfigFile reads the current file pre-substitution, and for each
value that matches what a ${VAR} reference would resolve to, restores the
original reference. Values the caller intentionally changed are kept as-is.

This fixes all 50+ writeConfigFile call sites (doctor, configure wizard,
gateway config.set/apply/patch, plugins, hooks, etc.) without requiring
any caller changes.

New files:
- src/config/env-preserve.ts — restoreEnvVarRefs() utility
- src/config/env-preserve.test.ts — 11 unit tests

* fix: remove global config env snapshot race

* docs(changelog): note config env snapshot race fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
hughdidit pushed a commit to hughdidit/DAISy-Agency that referenced this pull request Mar 1, 2026
…isk (openclaw#11560)

* fix: preserve ${VAR} env var references when writing config back to disk

Fixes openclaw#11466

When config is loaded, ${VAR} references are resolved to their plaintext
values. Previously, writeConfigFile would serialize the resolved values,
silently replacing "${ANTHROPIC_API_KEY}" with "sk-ant-api03-..." in the
config file.

Now writeConfigFile reads the current file pre-substitution, and for each
value that matches what a ${VAR} reference would resolve to, restores the
original reference. Values the caller intentionally changed are kept as-is.

This fixes all 50+ writeConfigFile call sites (doctor, configure wizard,
gateway config.set/apply/patch, plugins, hooks, etc.) without requiring
any caller changes.

New files:
- src/config/env-preserve.ts — restoreEnvVarRefs() utility
- src/config/env-preserve.test.ts — 11 unit tests

* fix: remove global config env snapshot race

* docs(changelog): note config env snapshot race fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
(cherry picked from commit a4f4b06)

# Conflicts:
#	CHANGELOG.md
#	src/config/io.ts
hughdidit pushed a commit to hughdidit/DAISy-Agency that referenced this pull request Mar 3, 2026
…isk (openclaw#11560)

* fix: preserve ${VAR} env var references when writing config back to disk

Fixes openclaw#11466

When config is loaded, ${VAR} references are resolved to their plaintext
values. Previously, writeConfigFile would serialize the resolved values,
silently replacing "${ANTHROPIC_API_KEY}" with "sk-ant-api03-..." in the
config file.

Now writeConfigFile reads the current file pre-substitution, and for each
value that matches what a ${VAR} reference would resolve to, restores the
original reference. Values the caller intentionally changed are kept as-is.

This fixes all 50+ writeConfigFile call sites (doctor, configure wizard,
gateway config.set/apply/patch, plugins, hooks, etc.) without requiring
any caller changes.

New files:
- src/config/env-preserve.ts — restoreEnvVarRefs() utility
- src/config/env-preserve.test.ts — 11 unit tests

* fix: remove global config env snapshot race

* docs(changelog): note config env snapshot race fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
(cherry picked from commit a4f4b06)

# Conflicts:
#	CHANGELOG.md
#	src/config/io.ts
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
…isk (openclaw#11560)

* fix: preserve ${VAR} env var references when writing config back to disk

Fixes openclaw#11466

When config is loaded, ${VAR} references are resolved to their plaintext
values. Previously, writeConfigFile would serialize the resolved values,
silently replacing "${ANTHROPIC_API_KEY}" with "sk-ant-api03-..." in the
config file.

Now writeConfigFile reads the current file pre-substitution, and for each
value that matches what a ${VAR} reference would resolve to, restores the
original reference. Values the caller intentionally changed are kept as-is.

This fixes all 50+ writeConfigFile call sites (doctor, configure wizard,
gateway config.set/apply/patch, plugins, hooks, etc.) without requiring
any caller changes.

New files:
- src/config/env-preserve.ts — restoreEnvVarRefs() utility
- src/config/env-preserve.test.ts — 11 unit tests

* fix: remove global config env snapshot race

* docs(changelog): note config env snapshot race fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
…isk (openclaw#11560)

* fix: preserve ${VAR} env var references when writing config back to disk

Fixes openclaw#11466

When config is loaded, ${VAR} references are resolved to their plaintext
values. Previously, writeConfigFile would serialize the resolved values,
silently replacing "${ANTHROPIC_API_KEY}" with "sk-ant-api03-..." in the
config file.

Now writeConfigFile reads the current file pre-substitution, and for each
value that matches what a ${VAR} reference would resolve to, restores the
original reference. Values the caller intentionally changed are kept as-is.

This fixes all 50+ writeConfigFile call sites (doctor, configure wizard,
gateway config.set/apply/patch, plugins, hooks, etc.) without requiring
any caller changes.

New files:
- src/config/env-preserve.ts — restoreEnvVarRefs() utility
- src/config/env-preserve.test.ts — 11 unit tests

* fix: remove global config env snapshot race

* docs(changelog): note config env snapshot race fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
…isk (openclaw#11560)

* fix: preserve ${VAR} env var references when writing config back to disk

Fixes openclaw#11466

When config is loaded, ${VAR} references are resolved to their plaintext
values. Previously, writeConfigFile would serialize the resolved values,
silently replacing "${ANTHROPIC_API_KEY}" with "sk-ant-api03-..." in the
config file.

Now writeConfigFile reads the current file pre-substitution, and for each
value that matches what a ${VAR} reference would resolve to, restores the
original reference. Values the caller intentionally changed are kept as-is.

This fixes all 50+ writeConfigFile call sites (doctor, configure wizard,
gateway config.set/apply/patch, plugins, hooks, etc.) without requiring
any caller changes.

New files:
- src/config/env-preserve.ts — restoreEnvVarRefs() utility
- src/config/env-preserve.test.ts — 11 unit tests

* fix: remove global config env snapshot race

* docs(changelog): note config env snapshot race fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Doctor/configure commands re-inject raw API keys into config file

3 participants