Discord: persist component registry to disk across gateway restarts#66241
Discord: persist component registry to disk across gateway restarts#66241geekhuashan wants to merge 6 commits intoopenclaw:mainfrom
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 Event-loop blocking synchronous disk I/O in Discord interaction hot paths (registry persistence)
DescriptionThe Discord component registry persistence mechanism performs synchronous serialization and file writes from code paths reachable during normal user interactions (e.g., button clicks / modal submissions).
Vulnerable code (hot path trigger + sync persistence): if (params.consume !== false) {
store.delete(params.id);
schedulePersistComponentRegistry();
}
...
setImmediate(() => {
_persistScheduled = false;
persistComponentRegistry();
});
...
const serialized = `${JSON.stringify(payload)}\n`;
fd = openSync(tmp, "wx", 0o600);
writeSync(fd, serialized);
renameSync(tmp, filePath);RecommendationAvoid synchronous disk I/O in interaction-handling paths. Suggested changes:
Example (async + throttle): import { promises as fsp } from "node:fs";
let lastPersistAt = 0;
let persistInFlight: Promise<void> | null = null;
function schedulePersistComponentRegistry(): void {
const now = Date.now();
if (persistInFlight) return;
if (now - lastPersistAt < 5000) return; // throttle to 1 write / 5s
persistInFlight = (async () => {
try {
lastPersistAt = Date.now();
const filePath = getRegistryPath();
assertSafeRegistryDestination(filePath);
const payload = { version: REGISTRY_FILE_VERSION,
components: [...getComponentEntriesStore().values()],
modals: [...getModalEntriesStore().values()],
};
const serialized = JSON.stringify(payload) + "\n";
await fsp.writeFile(filePath, serialized, { mode: 0o600 });
} finally {
persistInFlight = null;
}
})();
}This reduces event-loop blocking and makes interaction processing resilient under high concurrency. 2. 🟡 Arbitrary registry file read/write via environment-controlled HOME influencing safe base directory
DescriptionThe component registry persistence path hardening is based on
Vulnerable code (base directory derived from environment-influenced homedir): const baseDir = join(homedir(), ".openclaw", "cache");
...
const expectedBase = resolve(join(homedir(), ".openclaw", "cache")) + pathSep;RecommendationAvoid using an environment-influenced home directory as the trust anchor for filesystem writes/reads. Options (pick one appropriate to deployment model):
Example: prefer import { userInfo } from "node:os";
import { join, resolve, sep as pathSep } from "node:path";
const safeHome = userInfo().homedir; // not `HOME`
const baseDir = join(safeHome, ".openclaw", "cache");
const candidate = join(baseDir, "discord-component-registry.json");
const resolvedBase = resolve(baseDir) + pathSep;
if (!resolve(candidate).startsWith(resolvedBase)) throw new Error("invalid");Additionally, document that the service must not accept untrusted environment variables, or explicitly clear 3. 🟡 Tamperable persisted Discord component registry enables allowlist and route override bypass
DescriptionThe Discord component/modal registry is persisted to and reloaded from a JSON file under If an attacker can modify this JSON file (e.g., another local plugin running as the same OS user, compromised process, or misconfigured filesystem permissions), they can inject/alter entries to:
While there are guild/channel authorization checks, the per-component Vulnerable flow highlights:
Vulnerable code: // loads persisted entries (including allowedUsers/callbackData/sessionKey/agentId/accountId/messageId)
const componentsChanged = loadPersistedEntries(
parsed?.components,
getComponentEntriesStore(),
now,
isComponentEntryRecord,
);RecommendationTreat the persisted registry as untrusted input and prevent tampering from changing authorization/routing decisions. Options (best to combine):
Example: verify permissions/owner before loading (Node on POSIX): import { statSync } from "node:fs";
const st = statSync(filePath);
if ((st.mode & 0o077) !== 0) throw new Error("registry file must not be group/world accessible");
// additionally check st.uid === process.getuid() when available
Analyzed PR: #66241 at commit Last updated on: 2026-04-15T22:27:11Z |
Greptile SummaryAdds best-effort disk persistence for the Discord component/modal registry so interactive buttons and modals survive gateway restarts. The implementation is straightforward: a lazy one-time load on first access, with synchronous writes after every register/resolve/expire operation, all wrapped in All three findings below are P2 (non-blocking style/robustness suggestions). Confidence Score: 5/5Safe to merge; all findings are P2 quality/robustness suggestions with no blocking correctness issues. The persistence layer is correctly isolated from the in-memory path (errors are caught and logged, in-memory store is always the source of truth), types are fully serializable (no function refs), and the lazy-load flag prevents double loads. The three comments are forward-looking robustness improvements rather than present defects. extensions/discord/src/components-registry.ts — the three P2 comments all apply to this file Prompt To Fix All With AIThis is a comment left during a code review.
Path: extensions/discord/src/components-registry.ts
Line: 86-99
Comment:
**Synchronous write on every hot-path interaction**
`writeFileSync` (and `mkdirSync`) are called synchronously on every register, consume, and expire event. In the gateway's event loop, each Discord button click or modal submission blocks until the file write completes. On slower storage (SD card on a Raspberry Pi, NFS, or any rotational disk), this adds latency directly to interaction handling.
Consider debouncing the persist behind a `setImmediate`/`setTimeout(0)` so the hot path returns immediately and the write is coalesced across multiple near-simultaneous events:
```typescript
let _persistScheduled = false;
function schedulePersistComponentRegistry(): void {
if (_persistScheduled) return;
_persistScheduled = true;
setImmediate(() => {
_persistScheduled = false;
persistComponentRegistry();
});
}
```
Then call `schedulePersistComponentRegistry()` from `resolveEntry` and `registerDiscordComponentEntries` instead of `persistComponentRegistry()` directly. The `loadPersistedComponentRegistry` rewrite path can still call `persistComponentRegistry` directly since it runs synchronously during the lazy load anyway.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/discord/src/components-registry.ts
Line: 52-59
Comment:
**Weak entry guard — required structural fields not validated**
`isRegistryEntryRecord` only checks that `id` is a non-empty string, then the caller immediately casts the value to the full generic type `T`. For `DiscordComponentEntry`, `kind` (`"button" | "select" | "modal-trigger"`) is required; for `DiscordModalEntry`, `title` and `fields` are required. A persisted entry that passes this guard but is missing those fields will be stored and returned by `resolveDiscordComponentEntry` / `resolveDiscordModalEntry`, after which downstream code accessing `entry.kind` or `entry.fields` gets `undefined` — causing silent wrong behavior or a `TypeError`.
While the file is always written from valid in-memory data, a partial write, manual edit, or future format change can produce entries that pass this guard. Consider tightening the check:
```typescript
function isComponentEntryRecord(e: unknown): e is DiscordComponentEntry {
return isRegistryEntryRecord(e) &&
"kind" in (e as object) &&
["button","select","modal-trigger"].includes((e as {kind:unknown}).kind as string);
}
function isModalEntryRecord(e: unknown): e is DiscordModalEntry {
return isRegistryEntryRecord(e) &&
typeof (e as {title?:unknown}).title === "string" &&
Array.isArray((e as {fields?:unknown}).fields);
}
```
And use separate loaders for each store so the right guard is applied.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/discord/src/components-registry.ts
Line: 102-131
Comment:
**`version` field written but never read back**
`REGISTRY_FILE_VERSION = 1` is serialized into every file under the key `version`, but `loadPersistedComponentRegistry` never reads or validates this field — it just unpacks `components` and `modals` unconditionally. If a future version changes the on-disk shape (e.g., field renames, type changes to `fields`), stale v1 files will be loaded silently with the new code, and the only safeguard is `isRegistryEntryRecord`'s `id` check.
Adding a version guard costs one line and closes this gap:
```typescript
const parsed = typeof raw === "object" && raw !== null
? (raw as { version?: unknown; components?: unknown; modals?: unknown })
: undefined;
if (typeof parsed?.version !== "number" || parsed.version !== REGISTRY_FILE_VERSION) {
return; // unknown version — leave store empty, don't rewrite
}
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Discord: persist component registry to d..." | Re-trigger Greptile |
| function persistComponentRegistry(): void { | ||
| try { | ||
| mkdirSync(dirname(DISCORD_COMPONENT_REGISTRY_FILE), { recursive: true }); | ||
| const payload: PersistedRegistryFile = { | ||
| version: REGISTRY_FILE_VERSION, | ||
| components: [...getComponentEntriesStore().values()], | ||
| modals: [...getModalEntriesStore().values()], | ||
| }; | ||
| writeFileSync(DISCORD_COMPONENT_REGISTRY_FILE, `${JSON.stringify(payload)}\n`); | ||
| } catch (err) { | ||
| console.warn( | ||
| `discord component registry persist failed: ${err instanceof Error ? err.message : String(err)}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Synchronous write on every hot-path interaction
writeFileSync (and mkdirSync) are called synchronously on every register, consume, and expire event. In the gateway's event loop, each Discord button click or modal submission blocks until the file write completes. On slower storage (SD card on a Raspberry Pi, NFS, or any rotational disk), this adds latency directly to interaction handling.
Consider debouncing the persist behind a setImmediate/setTimeout(0) so the hot path returns immediately and the write is coalesced across multiple near-simultaneous events:
let _persistScheduled = false;
function schedulePersistComponentRegistry(): void {
if (_persistScheduled) return;
_persistScheduled = true;
setImmediate(() => {
_persistScheduled = false;
persistComponentRegistry();
});
}Then call schedulePersistComponentRegistry() from resolveEntry and registerDiscordComponentEntries instead of persistComponentRegistry() directly. The loadPersistedComponentRegistry rewrite path can still call persistComponentRegistry directly since it runs synchronously during the lazy load anyway.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/discord/src/components-registry.ts
Line: 86-99
Comment:
**Synchronous write on every hot-path interaction**
`writeFileSync` (and `mkdirSync`) are called synchronously on every register, consume, and expire event. In the gateway's event loop, each Discord button click or modal submission blocks until the file write completes. On slower storage (SD card on a Raspberry Pi, NFS, or any rotational disk), this adds latency directly to interaction handling.
Consider debouncing the persist behind a `setImmediate`/`setTimeout(0)` so the hot path returns immediately and the write is coalesced across multiple near-simultaneous events:
```typescript
let _persistScheduled = false;
function schedulePersistComponentRegistry(): void {
if (_persistScheduled) return;
_persistScheduled = true;
setImmediate(() => {
_persistScheduled = false;
persistComponentRegistry();
});
}
```
Then call `schedulePersistComponentRegistry()` from `resolveEntry` and `registerDiscordComponentEntries` instead of `persistComponentRegistry()` directly. The `loadPersistedComponentRegistry` rewrite path can still call `persistComponentRegistry` directly since it runs synchronously during the lazy load anyway.
How can I resolve this? If you propose a fix, please make it concise.| function isRegistryEntryRecord(entry: unknown): entry is { id: string } { | ||
| return ( | ||
| typeof entry === "object" && | ||
| entry !== null && | ||
| "id" in entry && | ||
| typeof (entry as { id: unknown }).id === "string" && | ||
| (entry as { id: string }).id.trim().length > 0 | ||
| ); |
There was a problem hiding this comment.
Weak entry guard — required structural fields not validated
isRegistryEntryRecord only checks that id is a non-empty string, then the caller immediately casts the value to the full generic type T. For DiscordComponentEntry, kind ("button" | "select" | "modal-trigger") is required; for DiscordModalEntry, title and fields are required. A persisted entry that passes this guard but is missing those fields will be stored and returned by resolveDiscordComponentEntry / resolveDiscordModalEntry, after which downstream code accessing entry.kind or entry.fields gets undefined — causing silent wrong behavior or a TypeError.
While the file is always written from valid in-memory data, a partial write, manual edit, or future format change can produce entries that pass this guard. Consider tightening the check:
function isComponentEntryRecord(e: unknown): e is DiscordComponentEntry {
return isRegistryEntryRecord(e) &&
"kind" in (e as object) &&
["button","select","modal-trigger"].includes((e as {kind:unknown}).kind as string);
}
function isModalEntryRecord(e: unknown): e is DiscordModalEntry {
return isRegistryEntryRecord(e) &&
typeof (e as {title?:unknown}).title === "string" &&
Array.isArray((e as {fields?:unknown}).fields);
}And use separate loaders for each store so the right guard is applied.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/discord/src/components-registry.ts
Line: 52-59
Comment:
**Weak entry guard — required structural fields not validated**
`isRegistryEntryRecord` only checks that `id` is a non-empty string, then the caller immediately casts the value to the full generic type `T`. For `DiscordComponentEntry`, `kind` (`"button" | "select" | "modal-trigger"`) is required; for `DiscordModalEntry`, `title` and `fields` are required. A persisted entry that passes this guard but is missing those fields will be stored and returned by `resolveDiscordComponentEntry` / `resolveDiscordModalEntry`, after which downstream code accessing `entry.kind` or `entry.fields` gets `undefined` — causing silent wrong behavior or a `TypeError`.
While the file is always written from valid in-memory data, a partial write, manual edit, or future format change can produce entries that pass this guard. Consider tightening the check:
```typescript
function isComponentEntryRecord(e: unknown): e is DiscordComponentEntry {
return isRegistryEntryRecord(e) &&
"kind" in (e as object) &&
["button","select","modal-trigger"].includes((e as {kind:unknown}).kind as string);
}
function isModalEntryRecord(e: unknown): e is DiscordModalEntry {
return isRegistryEntryRecord(e) &&
typeof (e as {title?:unknown}).title === "string" &&
Array.isArray((e as {fields?:unknown}).fields);
}
```
And use separate loaders for each store so the right guard is applied.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a209382628
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const DISCORD_COMPONENT_REGISTRY_FILE = | ||
| process.env.OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE ?? | ||
| join(homedir(), ".openclaw", "cache", "discord-component-registry.json"); |
There was a problem hiding this comment.
Resolve registry path from state dir, not home dir
The new registry file path is hardcoded to homedir()/.openclaw/..., so it ignores OPENCLAW_STATE_DIR and other state-dir overrides that the rest of OpenClaw uses. In deployments/tests that relocate state (for example daemon setups and isolated test state dirs), component data will be written outside the active state root and won't reliably survive restart in the expected environment, so users can still hit "component expired" after restart. This path should be derived from the shared state-dir helper (openclaw/plugin-sdk/state-paths) instead of raw home-directory joining.
Useful? React with 👍 / 👎.
| components: [...getComponentEntriesStore().values()], | ||
| modals: [...getModalEntriesStore().values()], | ||
| }; | ||
| writeFileSync(DISCORD_COMPONENT_REGISTRY_FILE, `${JSON.stringify(payload)}\n`); |
There was a problem hiding this comment.
Persist registry with atomic writes
Persisting with a direct writeFileSync overwrite can leave a truncated/partial JSON file if the process is interrupted mid-write (crash, kill, power loss). On next startup, loadPersistedComponentRegistry will fail to parse and all pending component/modal entries are dropped, which undermines the restart-survival behavior this change is meant to provide. Use an atomic write pattern (temp file + rename, or existing atomic JSON helpers) so readers always see either the previous complete snapshot or the new one.
Useful? React with 👍 / 👎.
- Path traversal: validate env-var path stays under ~/.openclaw/cache - DoS guard: reject persisted files larger than 1 MB - Schema validation: strict type guards for component/modal entries - expiresAt clamp: cap persisted expiry to TTL to prevent far-future tampering - File perms: mkdir 0o700, file 0o600, atomic rename via temp file - Entry cap: MAX_ENTRIES_PER_TYPE=10000 prevents unbounded map growth Fixes aisle-research-bot security findings on PR openclaw#66241.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1d053c7a5f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let normalized = normalizeEntryTimestamps(rawEntry, now, DEFAULT_COMPONENT_TTL_MS); | ||
| if (normalized.expiresAt) { | ||
| normalized = { | ||
| ...normalized, | ||
| expiresAt: clampExpiresAt( | ||
| normalized.createdAt ?? now, | ||
| normalized.expiresAt, | ||
| DEFAULT_COMPONENT_TTL_MS, |
There was a problem hiding this comment.
Preserve persisted custom TTL values on reload
registerDiscordComponentEntries accepts a caller-provided ttlMs, but reload always normalizes and then clamps persisted entries to DEFAULT_COMPONENT_TTL_MS (30 minutes). That means components intentionally registered with longer lifetimes are silently shortened after a gateway restart, so valid interactions can start returning “expired” only because the process restarted.
Useful? React with 👍 / 👎.
…ng event loop Synchronous writes on every button click/modal resolve were blocking the event loop on each interaction. Use setImmediate to coalesce rapid register/ resolve events into a single write per tick. The direct flush path is kept for startup cleanup (loadPersistedComponentRegistry) and explicit clears (clearDiscordComponentEntries) where immediate consistency is needed. Addresses Greptile P2 review comment on openclaw#66241.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb53c07d6b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
|
|
||
| function persistComponentRegistry(): void { | ||
| const filePath = getRegistryPath(); |
There was a problem hiding this comment.
Handle invalid registry path without throwing
getRegistryPath() can throw when OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE points outside ~/.openclaw/cache, but this call is outside the try in persistComponentRegistry (and the same pattern exists in loadPersistedComponentRegistry). In that misconfigured-env case, the setImmediate callback from schedulePersistComponentRegistry throws uncaught and can take down the gateway, so registry persistence stops being best-effort and interactive component handling can fail unexpectedly.
Useful? React with 👍 / 👎.
- Symlink hardening (CWE-59, high): realpath-verify cache dir, reject destination symlink via lstatSync before writing, open tmp with openSync + "wx" and a random name so attacker-staged files cannot be clobbered or followed. Load path now also refuses to read through a symlink. - Strip sensitive fields (CWE-922, medium): sessionKey / agentId / accountId / allowedUsers are no longer written to disk. Routing/auth state is rebuilt at send time, so persisting it only extends the lifetime of secrets beyond process memory. - Deep schema validation (CWE-20, medium): isOptionArray / isModalFieldRecord so malformed persisted entries are rejected before reaching interaction handlers that assume correct shapes. - Runtime cap + purge (CWE-400, medium): purgeExpired runs on every registerEntries call and MAX_ENTRIES_PER_TYPE is now enforced at registration time, not only at load.
|
Addressed all 4 Aisle findings in 1. Symlink-follow (high, CWE-59) — Added 2. Sensitive fields persistence (medium, CWE-922) — Added a 3. Malformed persisted entries DoS (medium, CWE-20) — Added 4. Unbounded growth (medium, CWE-400) — |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c3d92b47a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // accountId) and authorization fields (allowedUsers) are ephemeral by design | ||
| // and must not be written to disk — an attacker reading the cache must not be | ||
| // able to impersonate sessions or bypass allowlists after a gateway restart. | ||
| const SENSITIVE_ENTRY_FIELDS = ["sessionKey", "agentId", "accountId", "allowedUsers"] as const; |
There was a problem hiding this comment.
Preserve route override fields in persisted entries
Removing sessionKey, agentId, and accountId from persisted component records causes restored entries to lose their original routing context after a gateway restart. dispatchDiscordComponentEvent then falls back to resolveAgentComponentRoute(...), which can route to a different session/agent/account than the one that created the component (especially in multi-agent or cross-session setups), so clicks are handled by the wrong conversation even though the entry still exists.
Useful? React with 👍 / 👎.
| // accountId) and authorization fields (allowedUsers) are ephemeral by design | ||
| // and must not be written to disk — an attacker reading the cache must not be | ||
| // able to impersonate sessions or bypass allowlists after a gateway restart. | ||
| const SENSITIVE_ENTRY_FIELDS = ["sessionKey", "agentId", "accountId", "allowedUsers"] as const; |
There was a problem hiding this comment.
Persist component allowlists to keep post-restart auth
allowedUsers is stripped before writing the registry, but ensureComponentUserAllowed treats a missing allowlist as unrestricted access. That means user-scoped buttons/forms become usable by anyone after restart, creating a security and behavior regression where authorization depends on whether the process has restarted.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a77ff79d98
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Codex review: found issues before merge. What this changes: The PR adds lazy JSON persistence, validation, pruning, best-effort writes, and registry tests for Discord component/modal entries so interactions can survive gateway restarts. Maintainer follow-up before merge: This is an open implementation PR for a real user-visible Discord bug, but the remaining decision is maintainer/product direction: rework the contributor branch onto state-dir/plugin-state storage or ask for a replacement PR. The authorization/routing persistence shape also needs human owner review. Security review: Security review cleared: The diff adds local registry persistence and filesystem hardening but no new dependencies, CI/script execution, package metadata, secrets handling, or supply-chain surface; the remaining concern is the functional state-dir mismatch above. Review findings:
Review detailsBest possible solution: Rework this PR so Discord component and modal entries persist through the active OpenClaw state system, preferably via api.runtime.state.openKeyedStore for bundled plugins or, if a local file remains necessary, via resolveStateDir plus the existing lock and atomic JSON helpers. The end state should preserve route overrides and allowedUsers, validate persisted records before load, prune expiry, bound growth, and cover restart, malformed-file, expired-entry, authorization, and routing regressions. Do we have a high-confidence way to reproduce the issue? Yes. The source-level reproduction is clear: register a Discord component, restart the gateway so the process-global Maps are empty, then click the existing Discord button; current main resolves null and the handler replies that the component/form has expired. Is this the best way to solve the issue? No. The PR addresses the right bug, but the storage path is not the narrowest maintainable solution now that OpenClaw has state-dir based Discord persistence patterns and a bundled plugin keyed store suitable for restart-safe registries. Full review comments:
Overall correctness: patch is incorrect Acceptance criteria:
What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 581fbea1d653. |
Summary
Persist the in-memory Discord component/modal registry to
~/.openclaw/cache/discord-component-registry.json(override withOPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE) so interactive components stayresolvable after a gateway restart.
Today the registry lives only in a process-global
Mapkeyed onSymbol.for("openclaw.discord.componentEntries"). When the gatewayrestarts (upgrade, crash, LaunchAgent cycle, Mac sleep), every pending
button/modal becomes unroutable and the user sees "component expired"
even though the Discord message still shows a live button.
This is especially painful in multi-agent setups where cron-owned
components (daily check-ins, hourly prompts) are sent outside the
current Discord session and need to survive beyond the lifetime of one
gateway process.
Implementation
extensions/discord/src/components-registry.ts:loadPersistedComponentRegistry()that thegetComponentEntries/
getModalEntriesaccessors call the first time they are touched. Loadervalidates each record via a small
isRegistryEntryRecordtype guard, dropsexpired entries, and rewrites the file if anything was skipped.
persistComponentRegistry()which writes both maps out under aversioned envelope (
{ version: 1, components, modals }). It is calledafter register, successful resolve/consume, and delete-on-expiry.
join(homedir(), \".openclaw\", \"cache\", \"discord-component-registry.json\")and honors
OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILEso tests andalternate deployments can redirect it.
console.warnand thein-memory map is still trusted — persistence is best-effort.
No API surface changes;
registerDiscordComponentEntries,resolveDiscord*Entry,clearDiscordComponentEntrieskeep their existingsignatures.
Why not core
The registry is discord-specific (keyed on the two
Symbol.formapsalready defined here), so the persistence lives next to the existing
registry code rather than in a generic helper. If other channels grow a
similar need, the helpers here can move behind a shared
plugin-sdk/channel-registry-persistenceseam later.Test plan
registerDiscordComponentEntries→persistComponentRegistry→ new process →loadPersistedComponentRegistry→resolveDiscordComponentEntryreturns the same entry.
rewritten.
idfield is dropped withoutthrowing.
click the button — handler fires instead of "expired".
Rollout
No config required. Behavior is backwards compatible: absent or
unreadable file leaves the in-memory map empty (same as today). Set
OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILEto a tmp path in tests thatmust not touch the real cache.