Fix Discord /codex_resume picker expiration#51260
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 Global Discord component registry exposed via globalThis enables plugin-side interaction hijacking
DescriptionThe Discord component registry was changed from module-local 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):
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. RecommendationIf 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):
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...
}
Also consider making any global registry key non-discoverable (not 2. 🟡 Discord component button callbackData treated as raw CommandBody (internal command injection via one-click buttons)
DescriptionIn Discord component fallback handling, raw Because
This creates a confused-deputy / command-injection primitive if untrusted content can influence button Vulnerable code (button fallback uses raw callbackData): const eventText =
(consumed.kind === "button" ? consumed.callbackData?.trim() : undefined) ||
formatDiscordComponentEventText({ ... });And later (same file)
RecommendationTreat button Options (pick one):
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.
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
DescriptionThe Discord plugin binding approval flow now patches the original component message with the resolved approval/denial text.
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 },
);
}RecommendationAvoid putting the approval decision text into a public channel message. Safer options:
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 Last updated on: 2026-03-21T18:14:39Z |
Greptile SummaryThis PR fixes a Discord Key changes:
The implementation is clean and well-scoped. No issues were found. Confidence Score: 5/5
Last reviewed commit: "Discord: share compo..." |
There was a problem hiding this comment.
💡 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".
| const eventText = | ||
| consumed.callbackData?.trim() || | ||
| formatDiscordComponentEventText({ |
There was a problem hiding this comment.
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 👍 / 👎.
4438159 to
8eb2ecf
Compare
/codex_resume picker expiration
There was a problem hiding this comment.
💡 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, |
There was a problem hiding this comment.
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") { |
There was a problem hiding this comment.
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 👍 / 👎.
91ad835 to
f9d32ed
Compare
There was a problem hiding this comment.
💡 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".
| onMatched: async () => { | ||
| try { | ||
| await respond.acknowledge(); | ||
| } catch { |
There was a problem hiding this comment.
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 👍 / 👎.
1d76f78 to
9ae67f6
Compare
c2076df to
b1df298
Compare
There was a problem hiding this comment.
💡 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".
| onMatched: async () => { | ||
| try { | ||
| await respond.acknowledge(); | ||
| } catch { |
There was a problem hiding this comment.
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 👍 / 👎.
| const { body, buildResult } = await buildDiscordComponentPayload({ | ||
| spec, | ||
| opts, | ||
| accountId: accountInfo.accountId, |
There was a problem hiding this comment.
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 👍 / 👎.
b1df298 to
76eb184
Compare
* 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 ...
Merged via squash. Prepared head SHA: 76eb184 Co-authored-by: huntharo <[email protected]> Co-authored-by: huntharo <[email protected]> Reviewed-by: @huntharo
Merged via squash. Prepared head SHA: 76eb184 Co-authored-by: huntharo <[email protected]> Co-authored-by: huntharo <[email protected]> Reviewed-by: @huntharo
Merged via squash. Prepared head SHA: 76eb184 Co-authored-by: huntharo <[email protected]> Co-authored-by: huntharo <[email protected]> Reviewed-by: @huntharo
Merged via squash. Prepared head SHA: 76eb184 Co-authored-by: huntharo <[email protected]> Co-authored-by: huntharo <[email protected]> Reviewed-by: @huntharo
Summary
Describe the problem and fix in 2–5 bullets:
/codex_resumepicker buttons could immediately fail withThis component has expired.even though the picker message had just been sent.codexapp:<token>callbacks.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
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)
No)No)No)No)No)Yes, explain risk + mitigation:Repro + Verification
Environment
/codex_resumeSteps
/codex_resumein a Discord thread.Browse Projectsor one of the recent-session buttons from the picker.Expected
Actual
This component has expired.immediately after clicking the picker button.Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
/codex_resumeflow, including clicking the picker in a Discord thread and successfully resuming a session.pnpm testsuite.Review Conversations
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
Yes)No)No)Failure Recovery (if this breaks)
extensions/discord/src/components-registry.ts,extensions/discord/src/monitor/agent-components.tsexpiredresponsesRisks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.