Skip to content

Discord: persist component registry to disk across gateway restarts#66241

Open
geekhuashan wants to merge 6 commits intoopenclaw:mainfrom
geekhuashan:feat/discord-component-registry-persist
Open

Discord: persist component registry to disk across gateway restarts#66241
geekhuashan wants to merge 6 commits intoopenclaw:mainfrom
geekhuashan:feat/discord-component-registry-persist

Conversation

@geekhuashan
Copy link
Copy Markdown
Contributor

Summary

Persist the in-memory Discord component/modal registry to
~/.openclaw/cache/discord-component-registry.json (override with
OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE) so interactive components stay
resolvable after a gateway restart.

Today the registry lives only in a process-global Map keyed on
Symbol.for("openclaw.discord.componentEntries"). When the gateway
restarts (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:

  • Add a lazy loadPersistedComponentRegistry() that the getComponentEntries
    / getModalEntries accessors call the first time they are touched. Loader
    validates each record via a small isRegistryEntryRecord type guard, drops
    expired entries, and rewrites the file if anything was skipped.
  • Add persistComponentRegistry() which writes both maps out under a
    versioned envelope ({ version: 1, components, modals }). It is called
    after register, successful resolve/consume, and delete-on-expiry.
  • File path defaults to join(homedir(), \".openclaw\", \"cache\", \"discord-component-registry.json\")
    and honors OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE so tests and
    alternate deployments can redirect it.
  • Errors from read/write/parse are logged via console.warn and the
    in-memory map is still trusted — persistence is best-effort.

No API surface changes; registerDiscordComponentEntries,
resolveDiscord*Entry, clearDiscordComponentEntries keep their existing
signatures.

Why not core

The registry is discord-specific (keyed on the two Symbol.for maps
already 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-persistence seam later.

Test plan

  • Unit: round-trip registerDiscordComponentEntries
    persistComponentRegistry → new process →
    loadPersistedComponentRegistryresolveDiscordComponentEntry
    returns the same entry.
  • Unit: expired entries on disk are skipped and the file is
    rewritten.
  • Unit: malformed JSON / missing id field is dropped without
    throwing.
  • Manual: send a Discord button via OpenClaw, restart gateway,
    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_FILE to a tmp path in tests that
must not touch the real cache.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 14, 2026

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Event-loop blocking synchronous disk I/O in Discord interaction hot paths (registry persistence)
2 🟡 Medium Arbitrary registry file read/write via environment-controlled HOME influencing safe base directory
3 🟡 Medium Tamperable persisted Discord component registry enables allowlist and route override bypass
1. 🟠 Event-loop blocking synchronous disk I/O in Discord interaction hot paths (registry persistence)
Property Value
Severity High
CWE CWE-400
Location extensions/discord/src/components-registry.ts:273-331

Description

The 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).

  • resolveEntry() is called by resolveDiscordComponentEntry() / resolveDiscordModalEntry(), which are typically invoked for every interaction.
  • When an entry is consumed or expired, it calls schedulePersistComponentRegistry().
  • The scheduled callback calls persistComponentRegistry(), which performs CPU-heavy JSON.stringify() plus synchronous filesystem operations (openSync, writeSync, renameSync).
  • The debounce uses setImmediate and a single boolean flag, which only coalesces multiple calls within the same event-loop tick. Under sustained interaction traffic (many ticks), the code can still write to disk repeatedly, blocking the event loop and potentially causing degraded throughput or missed Discord heartbeats (availability/DoS).

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);

Recommendation

Avoid synchronous disk I/O in interaction-handling paths.

Suggested changes:

  1. Make persistence asynchronous and keep it off the critical path.
  2. Use a time-based debounce/throttle (e.g., at most once every N seconds) rather than setImmediate.
  3. Consider moving persistence to a background queue/worker and only persisting at shutdown or periodically.

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
Property Value
Severity Medium
CWE CWE-73
Location extensions/discord/src/components-registry.ts:28-41

Description

The component registry persistence path hardening is based on os.homedir(), which on many platforms is derived from environment variables (e.g., HOME, USERPROFILE). If an attacker can influence the process environment (common in some multi-tenant/job-runner/plugin-host setups), they can steer the base directory used for the “must be under ~/.openclaw/cache” check.

  • Input: process.env.HOME (via homedir()) and process.env.OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE
  • Checks: resolveRegistryPath() and assertSafeRegistryDestination() ensure the registry file stays under join(homedir(), '.openclaw', 'cache')
  • Problem: If homedir() is attacker-influenced, the “safe base” itself becomes attacker-chosen, enabling arbitrary file read/write with the privileges of the running process (e.g., set HOME=/etc and registry path /etc/.openclaw/cache/discord-component-registry.json).
  • Sinks: readFileSync(filePath) (read) and openSync/renameSync (write)

Vulnerable code (base directory derived from environment-influenced homedir):

const baseDir = join(homedir(), ".openclaw", "cache");
...
const expectedBase = resolve(join(homedir(), ".openclaw", "cache")) + pathSep;

Recommendation

Avoid using an environment-influenced home directory as the trust anchor for filesystem writes/reads.

Options (pick one appropriate to deployment model):

  1. Use a fixed, application-controlled base directory (recommended for services), e.g. /var/lib/openclaw (Linux) or a configurable path owned by the service account, provided via trusted config not untrusted env.

  2. Derive the home directory from OS user database rather than HOME (where supported), and/or reject HOME overrides in privileged contexts.

Example: prefer os.userInfo().homedir and ignore OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE unless it is relative to a fixed base:

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 HOME/OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE in the launcher/service unit.

3. 🟡 Tamperable persisted Discord component registry enables allowlist and route override bypass
Property Value
Severity Medium
CWE CWE-285
Location extensions/discord/src/components-registry.ts:168-399

Description

The Discord component/modal registry is persisted to and reloaded from a JSON file under ~/.openclaw/cache. On startup, the loader accepts sensitive runtime fields (e.g., allowedUsers, callbackData, sessionKey, agentId, accountId, messageId) from disk and directly repopulates the in-memory registries.

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:

  • Bypass per-component allowlists by setting allowedUsers to include themselves.
  • Trigger arbitrary callback payloads by setting callbackData, influencing downstream dispatch.
  • Force routing to different sessions/agents/accounts by setting sessionKey/agentId/accountId, which are later used as routeOverrides.

While there are guild/channel authorization checks, the per-component allowedUsers check is driven by the (tamperable) persisted value, and route override values are consumed when dispatching component events.

Vulnerable flow highlights:

  • Persisted file values are accepted as-is (type-checked only):
    • isComponentEntryRecord allows callbackData, sessionKey, agentId, accountId, messageId, allowedUsers.
    • loadPersistedComponentRegistry loads JSON and inserts entries into the registry.
  • Later, component handling uses these fields, including allowedUsers and route overrides.

Vulnerable code:

// loads persisted entries (including allowedUsers/callbackData/sessionKey/agentId/accountId/messageId)
const componentsChanged = loadPersistedEntries(
  parsed?.components,
  getComponentEntriesStore(),
  now,
  isComponentEntryRecord,
);

Recommendation

Treat the persisted registry as untrusted input and prevent tampering from changing authorization/routing decisions.

Options (best to combine):

  1. Do not persist security-sensitive fields. Persist only opaque IDs and expiry timestamps; keep allowedUsers, callbackData, and routing data purely in-memory.

  2. Add an integrity mechanism (e.g., HMAC over the file contents with a secret key stored outside the cache directory) and refuse to load if the MAC is invalid.

  3. Harden file validation on load:

    • Verify file ownership and mode (e.g., must be owned by the current uid and not group/world-writable).
    • Consider storing in an OS-protected location.

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
  1. Bind entries to their original context (guild/channel/message/user) and re-check those constraints at resolve time, not just from persisted values.

Analyzed PR: #66241 at commit 85d78af

Last updated on: 2026-04-15T22:27:11Z

@openclaw-barnacle openclaw-barnacle Bot added channel: discord Channel integration: discord size: S labels Apr 14, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 14, 2026

Greptile Summary

Adds 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 try/catch so failures stay non-fatal.

All three findings below are P2 (non-blocking style/robustness suggestions).

Confidence Score: 5/5

Safe 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 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.

---

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

Comment on lines +86 to +99
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)}`,
);
}
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.

P2 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.

Comment on lines +52 to +59
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
);
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.

P2 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.

Comment thread extensions/discord/src/components-registry.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +11 to +13
const DISCORD_COMPONENT_REGISTRY_FILE =
process.env.OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE ??
join(homedir(), ".openclaw", "cache", "discord-component-registry.json");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +168 to +175
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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment thread extensions/discord/src/components-registry.ts
…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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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.
@geekhuashan
Copy link
Copy Markdown
Contributor Author

Addressed all 4 Aisle findings in c3d92b47a:

1. Symlink-follow (high, CWE-59) — Added assertSafeRegistryDestination(): realpathSync on the directory to ensure it still resolves under ~/.openclaw/cache, lstatSync to refuse writing through an existing symlink, and switched the atomic write to openSync(tmp, 'wx', 0o600) with a random PID/time/random-suffix temp name so an attacker-staged tmp cannot be clobbered or followed. Load path also refuses lstatSync.isSymbolicLink().

2. Sensitive fields persistence (medium, CWE-922) — Added a SENSITIVE_ENTRY_FIELDS allow-strip list covering sessionKey, agentId, accountId, allowedUsers. persistComponentRegistry() maps entries through stripSensitiveFields() before serializing. Routing/auth state is rebuilt from the live config on send, so these fields never need to survive a restart.

3. Malformed persisted entries DoS (medium, CWE-20) — Added isOptionArray and isModalFieldRecord deep-validators. isComponentEntryRecord / isModalEntryRecord now reject element-level malformations in options / fields, not just the wrapper arrays. fields is also capped at MAX_STRING_ARRAY_LEN.

4. Unbounded growth (medium, CWE-400)purgeExpired() runs on every registerEntries call and the MAX_ENTRIES_PER_TYPE=10_000 cap is now enforced at registration time (not just at load). Over-cap registrations are logged and rejected rather than silently allowed to grow the map.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment thread extensions/discord/src/components-registry.ts
@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 28, 2026

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:

  • [P2] Use the active state directory for registry storage — extensions/discord/src/components-registry.ts:30-34
Review details

Best 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:

  • [P2] Use the active state directory for registry storage — extensions/discord/src/components-registry.ts:30-34
    resolveRegistryPath anchors the new registry under homedir()/.openclaw/cache, so deployments or tests that set OPENCLAW_STATE_DIR still write Discord component state outside the active state root. A gateway restarted with relocated state can therefore miss the registry this feature is supposed to preserve; derive the path from openclaw/plugin-sdk/state-paths or the bundled plugin keyed store instead.
    Confidence: 0.89

Overall correctness: patch is incorrect
Overall confidence: 0.86

Acceptance criteria:

  • pnpm test extensions/discord/src/components.test.ts extensions/discord/src/monitor/monitor.test.ts
  • pnpm test src/plugin-state/plugin-state-store.test.ts src/plugin-state/plugin-state-store.runtime.test.ts
  • pnpm check:changed

What I checked:

  • Current main registry is memory-only: The Discord registry on current main imports only the global singleton helper and stores component/modal entries in process-global Maps; register/resolve/clear mutate those Maps only, with no file load, state-dir path, lock, SQLite store, or JSON persistence. (extensions/discord/src/components-registry.ts:1, 581fbea1d653)
  • Current main still replies expired on registry miss: The Discord component handlers call resolveDiscordComponentEntry/resolveDiscordModalEntry and reply with expired-component/form messages when those lookups miss, so a restart that clears the in-memory Maps still produces the reported user-visible failure. (extensions/discord/src/monitor/agent-components.handlers.ts:49, 581fbea1d653)
  • Current tests do not cover restart persistence: Current main registry tests cover register/consume behavior and duplicate module-instance sharing, but no disk round trip, restart reload, malformed persisted file, expired persisted entry, or routing/authorization persistence regression. (extensions/discord/src/components.test.ts:91, 581fbea1d653)
  • Existing Discord persistence uses state-dir helpers: Discord model picker preferences already resolve storage through openclaw/plugin-sdk/state-paths and write through file-lock plus atomic JSON helpers, which is the local pattern this PR should follow if it keeps a file-backed implementation. (extensions/discord/src/monitor/model-picker-preferences.ts:4, 581fbea1d653)
  • Main now exposes bundled plugin keyed state: Current main exposes api.runtime.state.openKeyedStore for bundled plugins, backed by plugin-state storage under resolveStateDir, and the docs describe restart-safe keyed storage with TTL and max-entry limits. Public docs: docs/plugins/sdk-runtime.md. (docs/plugins/sdk-runtime.md:398, 581fbea1d653)
  • PR head anchors registry path to homedir cache: The PR branch resolves the registry file from homedir()/.openclaw/cache or OPENCLAW_DISCORD_COMPONENT_REGISTRY_FILE, so it ignores OPENCLAW_STATE_DIR and the runtime state root used elsewhere. (extensions/discord/src/components-registry.ts:30, 85d78af2c267)

Likely related people:

  • steipete: Local current-main history and the provided prior ClawSweeper context point to Peter as the recent maintainer for the Discord registry, interaction handling, and tests being reviewed; the local checkout history is compact, so this is routing signal rather than original-introduction attribution. (role: recent maintainer / Discord current-main routing owner; confidence: medium; commits: 7b2b0d07e84e, a820a307dfeb; files: extensions/discord/src/components-registry.ts, extensions/discord/src/monitor/agent-components.handlers.ts, extensions/discord/src/components.test.ts)
  • amknight: The related merged storage work added bundled plugin keyed state and explicitly called out Discord component/modal registries as a planned consumer migration, making this person relevant for the preferred storage direction. (role: adjacent storage-helper owner; confidence: medium; commits: fe457b26fac0; files: src/plugin-state/plugin-state-store.ts, src/plugins/registry.ts, docs/plugins/sdk-runtime.md)

Remaining risk / open question:

  • The PR writes Discord interaction routing and authorization metadata to disk, so maintainer review should confirm the intended persisted shape and retention policy before merge.
  • The branch predates or does not incorporate the merged plugin state store direction, so it may need rework rather than incremental patching on the JSON file approach.
  • No live Discord gateway restart smoke or CI result was available from the read-only review context.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 581fbea1d653.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant