openclaw - ✅(Solved) Fix config.write clobbers symlinked openclaw.json and serializes resolved SecretRef plaintext to disk [2 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

config.write in the gateway has two related bugs that together cause plaintext secrets to leak into ~/.openclaw/openclaw.json on every config touch:

  1. Atomic rename replaces symlinked config with a regular file. If ~/.openclaw/openclaw.json is a symlink (e.g. to a git-tracked workspace file), every config write clobbers the symlink with a freshly renamed regular file.
  2. Resolved SecretRef values are serialized back to disk. The writer emits resolved (materialized) secret values instead of the original SecretRef marker objects, contradicting the documented behavior:

    Marker persistence is source-authoritative: OpenClaw writes markers from the active source config snapshot (pre-resolution), not from resolved runtime secret values. — docs/reference/secretref-credential-surface.md (lines 120–121)

Combined, any version-bump/self-update/config touch results in plaintext bot tokens, API keys, etc. being persisted into a newly-created regular file that replaces the user's symlink.

Error Message

  1. Serialize from the source config snapshot (the one loaded before SecretRef resolution), not the runtime-resolved snapshot. An internal redactResolved(config, secretRefMap) pass before JSON.stringify would also work but is more error-prone than keeping the source snapshot around explicitly.

Root Cause

config.write in the gateway has two related bugs that together cause plaintext secrets to leak into ~/.openclaw/openclaw.json on every config touch:

  1. Atomic rename replaces symlinked config with a regular file. If ~/.openclaw/openclaw.json is a symlink (e.g. to a git-tracked workspace file), every config write clobbers the symlink with a freshly renamed regular file.
  2. Resolved SecretRef values are serialized back to disk. The writer emits resolved (materialized) secret values instead of the original SecretRef marker objects, contradicting the documented behavior:

    Marker persistence is source-authoritative: OpenClaw writes markers from the active source config snapshot (pre-resolution), not from resolved runtime secret values. — docs/reference/secretref-credential-surface.md (lines 120–121)

Combined, any version-bump/self-update/config touch results in plaintext bot tokens, API keys, etc. being persisted into a newly-created regular file that replaces the user's symlink.

Fix Action

Fix / Workaround

  1. Configure several fields as SecretRefs, e.g.:
    "channels": {
      "telegram": {
        "botToken": { "source": "env", "provider": "default", "id": "TELEGRAM_BOT_TOKEN" }
      }
    },
    "plugins": {
      "entries": {
        "google": {
          "config": {
            "webSearch": {
              "apiKey": { "source": "env", "provider": "default", "id": "GOOGLE_WEBSEARCH_API_KEY" }
            }
          }
        }
      }
    }
  2. Put the real file at ~/.openclaw/workspace/config/openclaw.json and make ~/.openclaw/openclaw.json a symlink to it.
  3. Trigger any config write — openclaw update, a meta.lastTouchedAt bump on restart, a config.patch, etc.
  4. After the write:
    • ~/.openclaw/openclaw.json is no longer a symlink; it is a freshly created regular file (new inode).
    • The symlink target (workspace/config/openclaw.json) is unchanged.
    • The new regular file contains resolved plaintext values in place of the SecretRef marker objects.

PR fix notes

PR #69416: fix: preserve symlinked config writes

Description (problem / solution / changelog)

Summary

Fixes #69396.

config.write had two related problems around config persistence:

  1. atomic temp-write + rename targeted the symlink path itself, so writing ~/.openclaw/openclaw.json replaced a user-managed symlink with a regular file
  2. runtime-derived writes need to keep serializing from the source snapshot so SecretRef marker objects stay on disk instead of resolved plaintext values

Root cause

The writer always created its temp file alongside configPath and renamed it back onto configPath. When that path was a symlink, the rename replaced the symlink inode instead of updating the symlink target.

The SecretRef side is protected by the runtime-source projection path, but this regression needed coverage at the writer boundary to make sure runtime-derived edits still round-trip back to the source marker objects.

What changed

  • resolve the real config file path before choosing the temp-file directory and rename/copy destination
  • keep backup maintenance and post-write stat collection aligned with that resolved write target
  • add a regression test that writes through a symlinked openclaw.json and verifies the symlink survives while the target file is updated
  • add a regression test that simulates a runtime snapshot with resolved credentials and verifies writeConfigFile(...) still persists the original SecretRef marker object, not plaintext

Testing

  • pnpm vitest run src/config/io.write-config.test.ts

Notes

  • I attempted the repo's full commit-time check pipeline, but it was killed by the execution environment after progressing through type generation and into the broader lint/import-cycle phase. The targeted config writer test suite above passed locally.

Changed files

  • src/agents/pi-embedded-runner/run.incomplete-turn.test.ts (modified, +43/-0)
  • src/agents/pi-embedded-runner/run.ts (modified, +10/-3)
  • src/agents/pi-embedded-runner/run/payloads.test.ts (modified, +32/-0)
  • src/agents/pi-embedded-runner/run/payloads.ts (modified, +3/-2)
  • src/config/io.ts (modified, +13/-9)
  • src/config/io.write-config.test.ts (modified, +138/-2)
  • ui/src/ui/controllers/chat.test.ts (modified, +82/-0)
  • ui/src/ui/controllers/chat.ts (modified, +65/-1)

PR #69438: fix(config/io): preserve symlinked config by writing through realpath

Description (problem / solution / changelog)

Summary

Partial fix for #69396. The config writer does an atomic temp-file + rename into configPath, which replaces the symlink with a regular file on every config touch — openclaw update, meta.lastTouchedAt bump on restart, config.patch, etc. This breaks the workspace-template deployment pattern documented in TOOLS.md (symlink `~/.openclaw/openclaw.json` → a git-tracked file under `workspace/config/`) and causes the runtime file to silently diverge from the user's source of truth.

Fix

Resolve the config path via `fs.promises.realpath` when the file already exists, then use that resolved path as both the temp-file parent directory and the rename / copy-fallback / chmod target. Rename stays atomic (same filesystem as the real file) while writing through the symlink instead of replacing it.

The raw `configPath` is intentionally still passed to:

  • audit logs (so the user-visible path is what's recorded)
  • backup rotation (`.bak` files live next to the symlink, the visible location)
  • `tightenStateDirPermissionsIfNeeded` (which intentionally checks the state-dir, not the symlink target)

Test plan

  • New regression test in `src/config/io.write-config.test.ts` gated on `process.platform !== "win32"`:
    • Creates `~/workspace/config/openclaw.json` as the real file
    • Symlinks `~/.openclaw/openclaw.json` → that real file
    • Writes a config change
    • Asserts `lstat` still reports a symbolic link, `stat` inode matches the real target, and the new port landed in the target file
  • `pnpm test src/config/io.write-config.test.ts` — 9/9 pass
  • Full `src/config/io.*` lane — 57/57 pass

Scope

This PR addresses only the symlink-clobber half of #69396. The companion SecretRef → plaintext serialization bug (the secrets ending up in the regular file after the clobber) is a deeper change in `resolvePersistCandidateForWrite` and wants its own focused PR — I'd rather not bundle the two. Happy to take that one next if the fix direction here looks right.

Notes

  • No behavior change when `~/.openclaw/openclaw.json` is a regular file (`realpath` returns the same path).
  • First-write case (no config yet) is unaffected — `realpath` is only consulted when `snapshot.exists` is true.
  • Windows path unchanged (test is gated; `realpath` on Windows works but the atomic-rename fallback already handles dest-exists via `copyFile`).

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/config/io.ts (modified, +16/-4)
  • src/config/io.write-config.test.ts (modified, +41/-0)

Code Example

"channels": {
     "telegram": {
       "botToken": { "source": "env", "provider": "default", "id": "TELEGRAM_BOT_TOKEN" }
     }
   },
   "plugins": {
     "entries": {
       "google": {
         "config": {
           "webSearch": {
             "apiKey": { "source": "env", "provider": "default", "id": "GOOGLE_WEBSEARCH_API_KEY" }
           }
         }
       }
     }
   }

---

{"ts":"2026-04-20T14:40:18.297Z","event":"config.write","configPath":"/home/quinton/.openclaw/openclaw.json","result":"rename","previousIno":"116226","nextIno":"260737","previousNlink":1,"nextNlink":1,"gatewayModeBefore":"local","gatewayModeAfter":"local", ...}

---

-      "botToken": {
-        "source": "env",
-        "provider": "default",
-        "id": "TELEGRAM_BOT_TOKEN"
-      },
+      "botToken": "8712...REDACTED...",
...
-        "apiKey": {
-          "source": "env",
-          "provider": "default",
-          "id": "GOOGLE_WEBSEARCH_API_KEY"
-        }
+        "apiKey": "AIza...REDACTED..."
RAW_BUFFERClick to expand / collapse

Summary

config.write in the gateway has two related bugs that together cause plaintext secrets to leak into ~/.openclaw/openclaw.json on every config touch:

  1. Atomic rename replaces symlinked config with a regular file. If ~/.openclaw/openclaw.json is a symlink (e.g. to a git-tracked workspace file), every config write clobbers the symlink with a freshly renamed regular file.
  2. Resolved SecretRef values are serialized back to disk. The writer emits resolved (materialized) secret values instead of the original SecretRef marker objects, contradicting the documented behavior:

    Marker persistence is source-authoritative: OpenClaw writes markers from the active source config snapshot (pre-resolution), not from resolved runtime secret values. — docs/reference/secretref-credential-surface.md (lines 120–121)

Combined, any version-bump/self-update/config touch results in plaintext bot tokens, API keys, etc. being persisted into a newly-created regular file that replaces the user's symlink.

Environment

  • OpenClaw: upgraded from 2026.4.152026.4.19-beta.2 during the reproduction
  • OS: Linux 6.6.87.2-microsoft-standard-WSL2 (x64), Node v22.22.1
  • Gateway mode: local, bind loopback
  • Config layout (per the TOOLS.md pattern shipped in the workspace template):
    • Real file: ~/.openclaw/workspace/config/openclaw.json (git-tracked)
    • ~/.openclaw/openclaw.json → symlink to the above

Reproduction

  1. Configure several fields as SecretRefs, e.g.:
    "channels": {
      "telegram": {
        "botToken": { "source": "env", "provider": "default", "id": "TELEGRAM_BOT_TOKEN" }
      }
    },
    "plugins": {
      "entries": {
        "google": {
          "config": {
            "webSearch": {
              "apiKey": { "source": "env", "provider": "default", "id": "GOOGLE_WEBSEARCH_API_KEY" }
            }
          }
        }
      }
    }
  2. Put the real file at ~/.openclaw/workspace/config/openclaw.json and make ~/.openclaw/openclaw.json a symlink to it.
  3. Trigger any config write — openclaw update, a meta.lastTouchedAt bump on restart, a config.patch, etc.
  4. After the write:
    • ~/.openclaw/openclaw.json is no longer a symlink; it is a freshly created regular file (new inode).
    • The symlink target (workspace/config/openclaw.json) is unchanged.
    • The new regular file contains resolved plaintext values in place of the SecretRef marker objects.

Evidence from logs/config-audit.jsonl

Every write records "result":"rename" and a brand-new inode:

{"ts":"2026-04-20T14:40:18.297Z","event":"config.write","configPath":"/home/quinton/.openclaw/openclaw.json","result":"rename","previousIno":"116226","nextIno":"260737","previousNlink":1,"nextNlink":1,"gatewayModeBefore":"local","gatewayModeAfter":"local", ...}

previousNlink: 1 on the old inode is expected for a symlink's target; the key signal is nextIno differing from previousIno on every write, i.e. the writer always creates a new file and renames it over the path rather than writing through the symlink.

Observed writes today (all clobbered the symlink):

  • 2026-04-20T14:29:25Z — previousIno 12575 → nextIno 21001
  • 2026-04-20T14:29:33Z — previousIno 21001 → nextIno 116226
  • 2026-04-20T14:40:18Z — previousIno 116226 → nextIno 260737 (coincided with lastTouchedVersion: "2026.4.15" → "2026.4.19-beta.2")

Diff of what got written

Minimum diff between the symlink target (source, with SecretRefs) and the regular file OpenClaw wrote:

-      "botToken": {
-        "source": "env",
-        "provider": "default",
-        "id": "TELEGRAM_BOT_TOKEN"
-      },
+      "botToken": "8712...REDACTED...",
...
-        "apiKey": {
-          "source": "env",
-          "provider": "default",
-          "id": "GOOGLE_WEBSEARCH_API_KEY"
-        }
+        "apiKey": "AIza...REDACTED..."

Affected fields in this repro:

  • channels.telegram.botToken
  • skills.entries.goplaces.apiKey
  • skills.entries.notion.apiKey
  • plugins.entries.google.config.webSearch.apiKey

All four are listed as supported SecretRef surfaces in docs/reference/secretref-credential-surface.md.

Expected behavior

  • Symlink preservation: the config writer should resolve the real path before the temp-write + rename (e.g. fs.realpathSync(configPath) as the rename target), or write-through the symlink. Atomic replace of a user-managed symlink is surprising and breaks common deployment patterns (git-tracked config, Nix/Chezmoi/dotfiles, etc.).
  • SecretRef preservation: per the documented contract, writes should serialize from the pre-resolution source config snapshot so SecretRef marker objects round-trip unchanged. Resolved plaintext values must never hit disk in the config file, regardless of what internal in-memory representation the gateway uses at runtime.

Impact

  • Security. Every self-update / config touch silently exfiltrates plaintext credentials into the primary config file. Any user who assumes SecretRefs keep plaintext out of openclaw.json (as the docs state) is wrong today. If that file is shell-history-grepped, backed up, committed, or opened in a shared screenshot, the secrets leak.
  • Deployment patterns break. The symlink-based workflow documented in workspace templates (TOOLS.md: "If OpenClaw ever stops following the symlink, copy the file back and drop the symlink") stops working on every write. Users either keep re-creating the symlink or give up and manage plaintext in place.
  • Config drift vs. source of truth. Users who version-control workspace/config/openclaw.json find their git-tracked file silently diverging from the runtime file.

Related issues (for context, not duplicates)

  • #53742 — macOS gateway install resolves SecretRef values into plaintext LaunchAgent plist (same "resolved values written to persistent artifact" class of bug, different artifact)
  • #67719 — Per-agent models.json catalog generator embeds plaintext credentials for OAuth/device-auth providers
  • #38622, #36754, #61585, #63969 — other places in OpenClaw where symlinks are not followed; same root symptom, different surfaces

Suggested fix direction

  1. In the config writer, resolve configPath to its real path (fs.promises.realpath) before picking the temp-file directory and rename target. This fixes the symlink clobber.
  2. Serialize from the source config snapshot (the one loaded before SecretRef resolution), not the runtime-resolved snapshot. An internal redactResolved(config, secretRefMap) pass before JSON.stringify would also work but is more error-prone than keeping the source snapshot around explicitly.
  3. Add a writer-level assertion / test: any field listed as a supported SecretRef surface in docs/reference/secretref-credential-surface.md must not emit a string value when the in-memory runtime state was populated from a SecretRef.

Happy to test a PR.

extent analysis

TL;DR

To fix the issue, modify the config writer to resolve the real path of the config file before writing and serialize the source config snapshot instead of the resolved runtime snapshot.

Guidance

  1. Resolve the real path: Use fs.promises.realpath to resolve the real path of the config file before picking the temp-file directory and rename target. This will prevent the symlink from being clobbered.
  2. Serialize from the source config snapshot: Instead of serializing the resolved runtime snapshot, use the source config snapshot loaded before SecretRef resolution. This will ensure that SecretRef marker objects are preserved.
  3. Add a writer-level assertion: Implement a test to verify that any field listed as a supported SecretRef surface does not emit a string value when the in-memory runtime state was populated from a SecretRef.

Example

const fs = require('fs/promises');

// Resolve the real path of the config file
const realConfigPath = await fs.realpath(configPath);

// Serialize from the source config snapshot
const sourceConfig = loadSourceConfig(); // Load the config before SecretRef resolution
const configToWrite = JSON.stringify(sourceConfig);

// Write the config to the resolved real path
await fs.writeFile(realConfigPath, configToWrite);

Notes

The suggested fix direction provided in the issue body is a good starting point. However, the implementation details may vary depending on the specific requirements and constraints of the OpenClaw project.

Recommendation

Apply the suggested fix direction, which involves resolving the real path of the config file and serializing from the source config snapshot. This will address the security and deployment pattern issues caused by the current implementation.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

  • Symlink preservation: the config writer should resolve the real path before the temp-write + rename (e.g. fs.realpathSync(configPath) as the rename target), or write-through the symlink. Atomic replace of a user-managed symlink is surprising and breaks common deployment patterns (git-tracked config, Nix/Chezmoi/dotfiles, etc.).
  • SecretRef preservation: per the documented contract, writes should serialize from the pre-resolution source config snapshot so SecretRef marker objects round-trip unchanged. Resolved plaintext values must never hit disk in the config file, regardless of what internal in-memory representation the gateway uses at runtime.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING