Skip to content

Fix Discord /codex_resume picker expiration#51260

Merged
huntharo merged 13 commits intoopenclaw:mainfrom
huntharo:codex/fix-browse-projects-crash-discord
Mar 21, 2026
Merged

Fix Discord /codex_resume picker expiration#51260
huntharo merged 13 commits intoopenclaw:mainfrom
huntharo:codex/fix-browse-projects-crash-discord

Conversation

@huntharo
Copy link
Copy Markdown
Member

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: Discord /codex_resume picker buttons could immediately fail with This component has expired. even though the picker message had just been sent.
  • Why it matters: resume-by-picker worked in Telegram but was broken in Discord, including the thread-in-channel-in-server flow used for real Codex resume.
  • What changed: I kept the Discord fallback path from discarding explicit callback payloads, and I moved the Discord component registry onto process-global singleton maps so senders and listeners share the same component state across duplicate module instances.
  • What did NOT change (scope boundary): I did not change the Codex app server picker generation logic; it was already emitting the expected codexapp:<token> callbacks.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #
  • Related #

User-visible / Behavior Changes

Discord Codex resume pickers now stay clickable and can successfully resume a session instead of immediately showing This component has expired.

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: local dev checkout
  • Model/provider: Codex app server integration
  • Integration/channel (if any): Discord thread inside a channel inside a server
  • Relevant config (redacted): Discord slash command flow with /codex_resume

Steps

  1. Run /codex_resume in a Discord thread.
  2. Click Browse Projects or one of the recent-session buttons from the picker.
  3. Repeat the interaction in the same Discord conversation.

Expected

  • The picker action is accepted and the selected Codex session view continues normally.

Actual

  • Before this fix, Discord responded with This component has expired. immediately after clicking the picker button.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: I verified the Discord end-to-end /codex_resume flow, including clicking the picker in a Discord thread and successfully resuming a session.
  • Edge cases checked: I also verified the focused Discord component tests covering callback fallback behavior and duplicate-module registry sharing.
  • What you did not verify: I did not run the full pnpm test suite.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: revert the two Discord component commits on this branch
  • Files/config to restore: extensions/discord/src/components-registry.ts, extensions/discord/src/monitor/agent-components.ts
  • Known bad symptoms reviewers should watch for: Discord component clicks failing to resolve, or plugin-owned pickers regressing to immediate expired responses

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk: sharing the registry globally could make stale component state survive duplicate module loads longer than before.
  • Mitigation: the existing TTL and explicit clear/consume paths are unchanged, and I added a regression test for cross-module sharing.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 20, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High Global Discord component registry exposed via globalThis enables plugin-side interaction hijacking
2 🟡 Medium Discord component button callbackData treated as raw CommandBody (internal command injection via one-click buttons)
3 🔵 Low Discord plugin bind approval outcome disclosed by editing the public prompt message

1. 🟠 Global Discord component registry exposed via globalThis enables plugin-side interaction hijacking

Property Value
Severity High
CWE CWE-284
Location extensions/discord/src/components-registry.ts:3-20

Description

The Discord component registry was changed from module-local Maps to process-global Maps stored on globalThis using Symbol.for(...) keys.

This creates a new, stable, guessable access path for any in-process code (including third-party plugins) to read and overwrite registered component entries/modals, which are later trusted when dispatching interactions.

Impact (assuming plugins are not fully trusted):

  • A plugin can obtain direct references to the registry maps via globalThis[Symbol.for(...)].
  • It can enumerate existing component IDs and their associated metadata (e.g., callbackData, sessionKey, agentId, accountId, allowedUsers, messageId).
  • It can overwrite entries (Map#set) to redirect future button/select/modal interactions to attacker-controlled callbackData and/or route overrides.
  • The interaction dispatch path resolves entries by componentId only (no mandatory binding to messageId/channel), so an overwritten entry can be used even when it does not actually belong to the interacted message.

Vulnerable code:

const DISCORD_COMPONENT_ENTRIES_KEY = Symbol.for("openclaw.discord.componentEntries");

function resolveGlobalMap<TKey, TValue>(key: symbol): Map<TKey, TValue> {
  const globalStore = globalThis as Record<PropertyKey, unknown>;
  if (globalStore[key] instanceof Map) {
    return globalStore[key] as Map<TKey, TValue>;
  }
  const created = new Map<TKey, TValue>();
  globalStore[key] = created;
  return created;
}

This is a capability/authorization issue at the plugin boundary: it bypasses package export boundaries and enables cross-plugin tampering with interactive routing state.

Recommendation

If plugins are not meant to be fully trusted, avoid exposing mutable interaction routing state through a globally discoverable key.

Recommended mitigations (choose based on intended trust model):

  1. Remove global exposure (strongest): keep the registry module-local (as before), or centralize it in a single internal module instance (dedupe imports) rather than using globalThis.

  2. Scope + validate lookups (reduces hijack blast radius): key entries by (messageId, componentId) and require interaction.message.id to match at resolution time.

Example approach:

// Store
componentEntries.set(`${messageId}:${entry.id}`, normalized);// Resolve
export function resolveDiscordComponentEntry(params: {
  messageId: string;
  id: string;
  consume?: boolean;
}): DiscordComponentEntry | null {
  const key = `${params.messageId}:${params.id}`;
  const entry = componentEntries.get(key);// ...expiry + consume...
}
  1. Hardening: refuse overwrites unless the caller proves ownership (e.g., store a per-message nonce generated at send-time and require it to register/overwrite entries).

Also consider making any global registry key non-discoverable (not Symbol.for with a public string) unless you explicitly accept that any in-process code can tamper with it.


2. 🟡 Discord component button callbackData treated as raw CommandBody (internal command injection via one-click buttons)

Property Value
Severity Medium
CWE CWE-77
Location extensions/discord/src/monitor/agent-components.ts:647-656

Description

In Discord component fallback handling, raw callbackData from a button is now used directly as the synthesized inbound event text when no plugin handler matches.

Because eventText is later copied into CommandBody/BodyForAgent, a button click can inject arbitrary slash/bang command strings into the normal command-processing pipeline.

  • callbackData is not validated/allow-listed for fallback handling
  • For buttons, eventText becomes callbackData.trim() (e.g. tests show /codex_resume --browse-projects)
  • dispatchDiscordComponentEvent() builds an inbound context where CommandBody is set to eventText, enabling built-in/plugin command parsing on a click

This creates a confused-deputy / command-injection primitive if untrusted content can influence button callbackData (e.g., LLM-generated interactive replies or plugin-provided components), turning a click on an innocuous-looking label into execution of internal chat commands (e.g., /reset, /config, !…), subject to whatever authorization gates evaluate true for the click sender.

Vulnerable code (button fallback uses raw callbackData):

const eventText =
  (consumed.kind === "button" ? consumed.callbackData?.trim() : undefined) ||
  formatDiscordComponentEventText({ ... });

And later (same file) eventText is propagated as command input:

  • CommandBody: eventText
  • BodyForAgent: eventText

Recommendation

Treat button callbackData as data, not as raw command text for the command parser.

Options (pick one):

  1. Do not feed callbackData into CommandBody (keep it for agent context only):
const callbackText = consumed.kind === "button" ? consumed.callbackData?.trim() : undefined;
const fallbackText = formatDiscordComponentEventText({
  kind: consumed.kind === "select" ? "select" : "button",
  label: consumed.label,
  values,
});

const eventTextForAgent = callbackText || fallbackText;
const eventTextForCommands = fallbackText; // prevents slash/bang injection via button value// then use eventTextForCommands for CommandBody / BodyForCommands,// and eventTextForAgent for BodyForAgent.
  1. If synthetic commands-from-buttons are required, gate them explicitly:
  • only allow when commandAuthorized === true and callbackData matches an allow-list of permitted commands/namespaces
  • otherwise fall back to formatDiscordComponentEventText().

This preserves Telegram-like behavior without allowing arbitrary command strings to be executed from untrusted button payloads.


3. 🔵 Discord plugin bind approval outcome disclosed by editing the public prompt message

Property Value
Severity Low
CWE CWE-200
Location extensions/discord/src/monitor/agent-components.ts:195-207

Description

The Discord plugin binding approval flow now patches the original component message with the resolved approval/denial text.

  • The edited message is a normal channel message (non-ephemeral), so anyone with access to the channel can see whether a plugin bind request was allowed or denied.
  • Previously, the flow cleared the buttons and delivered the decision via an ephemeral follow-up, avoiding disclosure of approval outcomes to other channel members.

Vulnerable code:

const approvalMessageId = params.messageId?.trim() || params.interaction.message?.id?.trim();
if (approvalMessageId) {
  await editDiscordComponentMessage(
    normalizedConversationId,
    approvalMessageId,
    { text: buildPluginBindingResolvedText(resolved) },
    { accountId: params.ctx.accountId },
  );
}

Recommendation

Avoid putting the approval decision text into a public channel message.

Safer options:

  1. Only clear/disable the components on the original message (no decision text), and always send the decision via an ephemeral follow-up to the clicker.
  2. If you must edit the original message, replace it with a generic status (e.g., "Approval handled") that does not disclose allow/deny outcome.

Example (generic edit + ephemeral follow-up):

await respond.clearComponents({ text: "Approval handled." });
await respond.followUp({
  text: buildPluginBindingResolvedText(resolved),
  ephemeral: true,
});

Analyzed PR: #51260 at commit 76eb184

Last updated on: 2026-03-21T18:14:39Z

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 20, 2026

Greptile Summary

This PR fixes a Discord /codex_resume picker regression where buttons would immediately show This component has expired. by addressing two root causes: (1) the component registry Maps were module-local, so separate ESM instances of the module couldn't share state between the sender and the listener; and (2) the built-in fallback path was discarding explicit callbackData payloads instead of forwarding them as the agent body (matching the Telegram behaviour).

Key changes:

  • components-registry.ts: replaces module-local Map declarations with a resolveGlobalMap helper that stores the Maps on globalThis under Symbol.for()-keyed symbols, ensuring all duplicate module instances share the same backing store while preserving the existing TTL / consume / clear semantics unchanged.
  • agent-components.ts: the fallback path after a plugin-dispatch miss now uses consumed.callbackData?.trim() as eventText before falling back to formatDiscordComponentEventText, so synthetic command strings (e.g. codexapp:<token>) are forwarded correctly.
  • Two new tests cover the cross-module-instance sharing contract and the raw-callbackData fallback path respectively.

The implementation is clean and well-scoped. No issues were found.

Confidence Score: 5/5

  • This PR is safe to merge — it is a targeted, well-tested bug fix with no backwards-incompatible changes and no new security surface.
  • Both root causes of the expiration bug are addressed with minimal, focused changes. The global-singleton pattern using Symbol.for() is an established idiom for cross-instance state sharing in Node.js and is implemented correctly. The callbackData fallback path is logically correct (the ?.trim() || idiom handles null, undefined, and empty-string edge cases properly). Two new regression tests directly cover the two changed code paths, and the existing test suite is not broken. No security, auth, or data-handling changes were introduced.
  • No files require special attention.

Last reviewed commit: "Discord: share compo..."

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: 05f2f42b85

ℹ️ 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 +619 to +621
const eventText =
consumed.callbackData?.trim() ||
formatDiscordComponentEventText({
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 Include select values in callback fallback

When a select entry has callbackData and no plugin handler matches, this fallback now sends only the trimmed callback string and drops values entirely. In that path, different user selections become indistinguishable to the built-in dispatcher, so workflows that rely on the chosen option (for example one command with multiple selectable targets) will route the same event text regardless of what was picked. This regression was introduced by preferring callbackData unconditionally in the fallback branch.

Useful? React with 👍 / 👎.

@openclaw-barnacle openclaw-barnacle bot added channel: discord Channel integration: discord size: S maintainer Maintainer-authored PR labels Mar 20, 2026
@huntharo huntharo self-assigned this Mar 20, 2026
@openclaw-barnacle openclaw-barnacle bot added scripts Repository scripts size: M and removed size: S labels Mar 20, 2026
@huntharo huntharo force-pushed the codex/fix-browse-projects-crash-discord branch from 4438159 to 8eb2ecf Compare March 21, 2026 01:19
@huntharo huntharo changed the title Fix Discord Codex resume picker expiration Fix Discord /codex_resume picker expiration Mar 21, 2026
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: 8eb2ecf3a3

ℹ️ 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".

interaction: params.interaction,
label: params.label,
componentLabel: params.componentLabel,
defer: false,
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 Re-enable immediate defer for component callbacks

Setting defer: false here removes the early Discord acknowledgment before callback dispatch, so any callback path that takes more than ~3 seconds before calling respond.* (for example, a plugin handler doing network or disk work) can hit Discord’s interaction timeout and surface “This interaction failed” to users. In this function, plugin callback dispatch is awaited before the fallback reply("✓"), so the timeout window is real for matched handlers that respond late.

Useful? React with 👍 / 👎.

} catch {
// Interaction may no longer accept a direct reply.
}
if (resolved.status !== "approved") {
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 Keep confirmation fallback when approval edit fails

The new branch only sends a follow-up when status is not approved, but editDiscordComponentMessage errors are already caught and logged. If that edit fails (e.g., message deleted or edit route rejected), approved decisions now return with no user-visible confirmation at all, whereas the previous flow always attempted a follow-up/reply fallback. This makes successful approvals appear as no-op interactions in those failure cases.

Useful? React with 👍 / 👎.

@huntharo huntharo force-pushed the codex/fix-browse-projects-crash-discord branch from 91ad835 to f9d32ed Compare March 21, 2026 13:10
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: f9d32ed505

ℹ️ 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 +245 to +248
onMatched: async () => {
try {
await respond.acknowledge();
} catch {
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 Acknowledge only after deciding to stay in plugin flow

Calling respond.acknowledge() unconditionally in onMatched changes downstream reply semantics for every matched Discord callback: this file already documents that reply() after acknowledge() edits @original. That means handlers that return { handled: false } now fall through to the built-in path and the later interaction.reply({ content: "✓" ... }) can overwrite the source component message instead of sending an ephemeral ack; similarly, plugin handlers that use respond.reply() will edit the original message unexpectedly. This shows up in real Discord interactions where acknowledge exists, even though tests using interactions without acknowledge won’t surface it.

Useful? React with 👍 / 👎.

@huntharo huntharo force-pushed the codex/fix-browse-projects-crash-discord branch 5 times, most recently from 1d76f78 to 9ae67f6 Compare March 21, 2026 15:25
@openclaw-barnacle openclaw-barnacle bot added the commands Command implementations label Mar 21, 2026
@huntharo huntharo force-pushed the codex/fix-browse-projects-crash-discord branch 2 times, most recently from c2076df to b1df298 Compare March 21, 2026 15:40
@openclaw-barnacle openclaw-barnacle bot removed the commands Command implementations label Mar 21, 2026
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: b1df2980fd

ℹ️ 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 +245 to +248
onMatched: async () => {
try {
await respond.acknowledge();
} catch {
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 Delay auto-acknowledge until plugin confirms it will handle

The new onMatched hook acknowledges every matched Discord callback before the plugin handler runs, so handlers that return { handled: false } now fall through with an already-acknowledged interaction. In this file, replies after acknowledge are documented to edit @original, and the fallback path still calls interaction.reply({ content: "✓", ... }), which can overwrite the source component message instead of sending an ephemeral ack. Fresh evidence: the new reply-after-ack behavior is explicitly encoded in updateOriginalMessage and combined with unconditional onMatched acknowledgment.

Useful? React with 👍 / 👎.

Comment on lines +201 to +204
const { body, buildResult } = await buildDiscordComponentPayload({
spec,
opts,
accountId: accountInfo.accountId,
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 Omit reply references when building edit payloads

editDiscordComponentMessage now reuses buildDiscordComponentPayload, which also carries send-only options like replyTo. If callers pass a reused send options object with replyTo, the edit request body includes message_reference, which is not part of normal message-edit semantics and can make the PATCH fail instead of updating components. This regression is introduced by sharing the same payload builder between create and edit paths without filtering edit-incompatible fields.

Useful? React with 👍 / 👎.

@huntharo huntharo force-pushed the codex/fix-browse-projects-crash-discord branch from b1df298 to 76eb184 Compare March 21, 2026 16:58
@huntharo huntharo merged commit e24bf22 into openclaw:main Mar 21, 2026
22 checks passed
@huntharo huntharo deleted the codex/fix-browse-projects-crash-discord branch March 21, 2026 18:30
mrosmarin added a commit to mrosmarin/openclaw that referenced this pull request Mar 21, 2026
* main: (516 commits)
  fix: use content hash for memory flush dedup instead of compactionCount (openclaw#30115) (openclaw#34222)
  fix(tts): add matrix to VOICE_BUBBLE_CHANNELS (openclaw#37080)
  feat(memory): pluggable system prompt section for memory plugins (openclaw#40126)
  fix: detect nvm services from installed command (openclaw#51146)
  fix: handle Linux nvm CA env before startup (openclaw#51146) (thanks @GodsBoy)
  refactor: route Telegram runtime through plugin sdk (openclaw#51772)
  refactor: route iMessage runtime through plugin sdk (openclaw#51770)
  refactor: route Slack runtime through plugin sdk (openclaw#51766)
  refactor(doctor): extract provider and shared config helpers (openclaw#51753)
  Fix Discord `/codex_resume` picker expiration (openclaw#51260)
  fix(ci): remove duplicate embedding default export
  fix(ci): restore embedding defaults and plugin boundaries
  fix: compaction safeguard summary budget (openclaw#27727)
  web UI: fix context notice using accumulated inputTokens instead of prompt snapshot (openclaw#51721)
  fix(status): skip cold-start status probes
  refactor(doctor): extract telegram provider warnings (openclaw#51704)
  fix(telegram): default fresh setups to mention-gated groups
  docs(changelog): note telegram doctor first-run guidance
  fix(doctor): add telegram first-run guidance
  fix(doctor): suppress telegram fresh-install group warning
  ...
JohnJAS pushed a commit to JohnJAS/openclaw that referenced this pull request Mar 22, 2026
Merged via squash.

Prepared head SHA: 76eb184
Co-authored-by: huntharo <[email protected]>
Co-authored-by: huntharo <[email protected]>
Reviewed-by: @huntharo
pholpaphankorn pushed a commit to pholpaphankorn/openclaw that referenced this pull request Mar 22, 2026
Merged via squash.

Prepared head SHA: 76eb184
Co-authored-by: huntharo <[email protected]>
Co-authored-by: huntharo <[email protected]>
Reviewed-by: @huntharo
frankekn pushed a commit to artwalker/openclaw that referenced this pull request Mar 23, 2026
Merged via squash.

Prepared head SHA: 76eb184
Co-authored-by: huntharo <[email protected]>
Co-authored-by: huntharo <[email protected]>
Reviewed-by: @huntharo
furaul pushed a commit to furaul/openclaw that referenced this pull request Mar 24, 2026
Merged via squash.

Prepared head SHA: 76eb184
Co-authored-by: huntharo <[email protected]>
Co-authored-by: huntharo <[email protected]>
Reviewed-by: @huntharo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: discord Channel integration: discord maintainer Maintainer-authored PR scripts Repository scripts size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant