Plugins: add binding resolution callbacks#48678
Conversation
🔒 Aisle Security AnalysisWe found 1 potential security issue(s) in this PR:
1. 🟡 Potential event-loop starvation / DoS via queueMicrotask dispatch of plugin conversation-binding resolved callbacks
DescriptionThe new This introduces a denial-of-service risk and may undermine the stated goal of keeping platform interaction acknowledgements fast:
Vulnerable code: queueMicrotask(() => {
void notifyPluginConversationBindingResolved(params).catch((error) => {
log.warn(`plugin binding resolved dispatch failed: ${String(error)}`);
});
});RecommendationUse a macrotask-based deferral (or a bounded background queue) so the platform ack can be flushed before executing plugin work, and add isolation controls. Safer scheduling option (yields to I/O): function dispatchPluginConversationBindingResolved(params: {...}): void {
setImmediate(() => {
void notifyPluginConversationBindingResolved(params).catch((error) => {
log.warn(`plugin binding resolved dispatch failed: ${String(error)}`);
});
});
}Additionally consider:
Analyzed PR: #48678 at commit Last updated on: 2026-03-17T17:49:35Z |
Greptile SummaryThis PR adds an
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/plugins/types.ts
Line: 960-976
Comment:
**Use a discriminated union for type-safe narrowing**
`PluginConversationBindingResolvedEvent` is a flat type with `binding?: PluginConversationBinding` and `decision: PluginConversationBindingResolutionDecision`. This means TypeScript won't prevent plugin authors from accessing `event.binding` as possibly `undefined` when `event.status === "approved"`, and won't narrow out `"deny"` from `decision` when the status is `"approved"`. The internal `PluginBindingResolveResult` already models this correctly with a discriminated union, and the public contract should follow the same pattern.
```suggestion
export type PluginConversationBindingResolvedEvent =
| {
status: "approved";
binding: PluginConversationBinding;
decision: Exclude<PluginConversationBindingResolutionDecision, "deny">;
request: {
summary?: string;
detachHint?: string;
requestedBySenderId?: string;
conversation: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
threadId?: string | number;
};
};
}
| {
status: "denied";
binding?: never;
decision: "deny";
request: {
summary?: string;
detachHint?: string;
requestedBySenderId?: string;
conversation: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
threadId?: string | number;
};
};
};
```
With this shape, narrowing on `event.status === "approved"` will guarantee `event.binding` is defined and exclude `"deny"` from `event.decision`, and narrowing on `"denied"` will guarantee `event.decision === "deny"` — consistent with how `PluginBindingResolveResult` is already typed internally.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/plugins/conversation-binding.test.ts
Line: 350-406
Comment:
**Missing `allow-always` callback coverage**
The two new tests cover `allow-once` approval and `deny`, but the `allow-always` decision path through `resolvePluginConversationBindingApproval` is not exercised by the new callback tests. `allow-always` takes a slightly different code path — it first calls `addPersistentApproval` before `bindConversationNow` — so a dedicated test confirming the callback fires (and carries `decision: "allow-always"`) after the persistent-approval write would complete the regression suite and guard against a future regression where, for example, an early return or exception inside `addPersistentApproval` silently skips the notification.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: d6a1ee8 |
| export type PluginConversationBindingResolvedEvent = { | ||
| status: "approved" | "denied"; | ||
| binding?: PluginConversationBinding; | ||
| decision: PluginConversationBindingResolutionDecision; | ||
| request: { | ||
| summary?: string; | ||
| detachHint?: string; | ||
| requestedBySenderId?: string; | ||
| conversation: { | ||
| channel: string; | ||
| accountId: string; | ||
| conversationId: string; | ||
| parentConversationId?: string; | ||
| threadId?: string | number; | ||
| }; | ||
| }; | ||
| }; |
There was a problem hiding this comment.
Use a discriminated union for type-safe narrowing
PluginConversationBindingResolvedEvent is a flat type with binding?: PluginConversationBinding and decision: PluginConversationBindingResolutionDecision. This means TypeScript won't prevent plugin authors from accessing event.binding as possibly undefined when event.status === "approved", and won't narrow out "deny" from decision when the status is "approved". The internal PluginBindingResolveResult already models this correctly with a discriminated union, and the public contract should follow the same pattern.
| export type PluginConversationBindingResolvedEvent = { | |
| status: "approved" | "denied"; | |
| binding?: PluginConversationBinding; | |
| decision: PluginConversationBindingResolutionDecision; | |
| request: { | |
| summary?: string; | |
| detachHint?: string; | |
| requestedBySenderId?: string; | |
| conversation: { | |
| channel: string; | |
| accountId: string; | |
| conversationId: string; | |
| parentConversationId?: string; | |
| threadId?: string | number; | |
| }; | |
| }; | |
| }; | |
| export type PluginConversationBindingResolvedEvent = | |
| | { | |
| status: "approved"; | |
| binding: PluginConversationBinding; | |
| decision: Exclude<PluginConversationBindingResolutionDecision, "deny">; | |
| request: { | |
| summary?: string; | |
| detachHint?: string; | |
| requestedBySenderId?: string; | |
| conversation: { | |
| channel: string; | |
| accountId: string; | |
| conversationId: string; | |
| parentConversationId?: string; | |
| threadId?: string | number; | |
| }; | |
| }; | |
| } | |
| | { | |
| status: "denied"; | |
| binding?: never; | |
| decision: "deny"; | |
| request: { | |
| summary?: string; | |
| detachHint?: string; | |
| requestedBySenderId?: string; | |
| conversation: { | |
| channel: string; | |
| accountId: string; | |
| conversationId: string; | |
| parentConversationId?: string; | |
| threadId?: string | number; | |
| }; | |
| }; | |
| }; |
With this shape, narrowing on event.status === "approved" will guarantee event.binding is defined and exclude "deny" from event.decision, and narrowing on "denied" will guarantee event.decision === "deny" — consistent with how PluginBindingResolveResult is already typed internally.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/types.ts
Line: 960-976
Comment:
**Use a discriminated union for type-safe narrowing**
`PluginConversationBindingResolvedEvent` is a flat type with `binding?: PluginConversationBinding` and `decision: PluginConversationBindingResolutionDecision`. This means TypeScript won't prevent plugin authors from accessing `event.binding` as possibly `undefined` when `event.status === "approved"`, and won't narrow out `"deny"` from `decision` when the status is `"approved"`. The internal `PluginBindingResolveResult` already models this correctly with a discriminated union, and the public contract should follow the same pattern.
```suggestion
export type PluginConversationBindingResolvedEvent =
| {
status: "approved";
binding: PluginConversationBinding;
decision: Exclude<PluginConversationBindingResolutionDecision, "deny">;
request: {
summary?: string;
detachHint?: string;
requestedBySenderId?: string;
conversation: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
threadId?: string | number;
};
};
}
| {
status: "denied";
binding?: never;
decision: "deny";
request: {
summary?: string;
detachHint?: string;
requestedBySenderId?: string;
conversation: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
threadId?: string | number;
};
};
};
```
With this shape, narrowing on `event.status === "approved"` will guarantee `event.binding` is defined and exclude `"deny"` from `event.decision`, and narrowing on `"denied"` will guarantee `event.decision === "deny"` — consistent with how `PluginBindingResolveResult` is already typed internally.
How can I resolve this? If you propose a fix, please make it concise.| it("notifies the owning plugin when a bind approval is approved", async () => { | ||
| const registry = createEmptyPluginRegistry(); | ||
| const onResolved = vi.fn(async () => undefined); | ||
| registry.conversationBindingResolvedHandlers.push({ | ||
| pluginId: "codex", | ||
| pluginRoot: "/plugins/callback-test", | ||
| handler: onResolved, | ||
| source: "/plugins/callback-test/index.ts", | ||
| rootDir: "/plugins/callback-test", | ||
| }); | ||
| setActivePluginRegistry(registry); | ||
|
|
||
| const request = await requestPluginConversationBinding({ | ||
| pluginId: "codex", | ||
| pluginName: "Codex App Server", | ||
| pluginRoot: "/plugins/callback-test", | ||
| requestedBySenderId: "user-1", | ||
| conversation: { | ||
| channel: "discord", | ||
| accountId: "isolated", | ||
| conversationId: "channel:callback-test", | ||
| }, | ||
| binding: { summary: "Bind this conversation to Codex thread abc." }, | ||
| }); | ||
|
|
||
| expect(request.status).toBe("pending"); | ||
| if (request.status !== "pending") { | ||
| throw new Error("expected pending bind request"); | ||
| } | ||
|
|
||
| const approved = await resolvePluginConversationBindingApproval({ | ||
| approvalId: request.approvalId, | ||
| decision: "allow-once", | ||
| senderId: "user-1", | ||
| }); | ||
|
|
||
| expect(approved.status).toBe("approved"); | ||
| expect(onResolved).toHaveBeenCalledWith({ | ||
| status: "approved", | ||
| binding: expect.objectContaining({ | ||
| pluginId: "codex", | ||
| pluginRoot: "/plugins/callback-test", | ||
| conversationId: "channel:callback-test", | ||
| }), | ||
| decision: "allow-once", | ||
| request: { | ||
| summary: "Bind this conversation to Codex thread abc.", | ||
| detachHint: undefined, | ||
| requestedBySenderId: "user-1", | ||
| conversation: { | ||
| channel: "discord", | ||
| accountId: "isolated", | ||
| conversationId: "channel:callback-test", | ||
| }, | ||
| }, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Missing
allow-always callback coverage
The two new tests cover allow-once approval and deny, but the allow-always decision path through resolvePluginConversationBindingApproval is not exercised by the new callback tests. allow-always takes a slightly different code path — it first calls addPersistentApproval before bindConversationNow — so a dedicated test confirming the callback fires (and carries decision: "allow-always") after the persistent-approval write would complete the regression suite and guard against a future regression where, for example, an early return or exception inside addPersistentApproval silently skips the notification.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/conversation-binding.test.ts
Line: 350-406
Comment:
**Missing `allow-always` callback coverage**
The two new tests cover `allow-once` approval and `deny`, but the `allow-always` decision path through `resolvePluginConversationBindingApproval` is not exercised by the new callback tests. `allow-always` takes a slightly different code path — it first calls `addPersistentApproval` before `bindConversationNow` — so a dedicated test confirming the callback fires (and carries `decision: "allow-always"`) after the persistent-approval write would complete the regression suite and guard against a future regression where, for example, an early return or exception inside `addPersistentApproval` silently skips the notification.
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: d6a1ee89bb
ℹ️ 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".
| conversation: params.request.conversation, | ||
| }, | ||
| }; | ||
| await registration.handler(event); |
There was a problem hiding this comment.
Run resolution callbacks off the approval hot path
resolvePluginConversationBindingApproval now waits for each registered onConversationBindingResolved handler, so a slow plugin callback (for example, network I/O) can stall approval handling long enough to miss interaction deadlines. This is user-visible because channel handlers await resolvePluginConversationBindingApproval before completing their response flow (e.g., Discord/Telegram button handling), so approvals can succeed internally while the user sees timeout/failed-interaction errors; callback execution should be detached or time-bounded so it cannot block the approval response path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 18e35a720c
ℹ️ 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".
| conversation: params.request.conversation, | ||
| }, | ||
| }; | ||
| await registration.handler(event); |
There was a problem hiding this comment.
Move resolution callbacks off the approval hot path
Awaiting registration.handler(event) here puts arbitrary plugin work (including network I/O) directly on the approval response path. Fresh evidence: Discord, Telegram, and Slack each await resolvePluginConversationBindingApproval(...) before sending their interaction acknowledgements (extensions/discord/src/monitor/agent-components.ts:860, extensions/telegram/src/bot-handlers.ts:1232, extensions/slack/src/monitor/events/interactions.block-actions.ts:480), so one slow callback can push the response past platform deadlines and produce timeout/failed-interaction UX even when the approval itself succeeded.
Useful? React with 👍 / 👎.
18e35a7 to
0d2d7e7
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2abaef2efa
ℹ️ 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".
| request: PendingPluginBindingRequest; | ||
| }): void { | ||
| // Keep platform interaction acks fast even if the plugin does slow post-bind work. | ||
| queueMicrotask(() => { |
There was a problem hiding this comment.
Defer callback dispatch past interaction-ack continuation
dispatchPluginConversationBindingResolved now uses queueMicrotask, but that still runs before the caller resumes after await resolvePluginConversationBindingApproval(...); in the Discord/Telegram/Slack approval handlers that means plugin callback code executes before the interaction acknowledgement path. If a plugin callback does synchronous work (or chains sync-heavy handlers before its first await), approvals can still hit timeout UX even though binding succeeded, so this should be moved to a macrotask-style detachment (setImmediate/setTimeout(0)) or another fully off-path dispatcher. Fresh evidence is the new queueMicrotask(...) scheduling added here, which preserves pre-ack execution order.
Useful? React with 👍 / 👎.
22e1708 to
dd31601
Compare
dd31601 to
6d7b32b
Compare
|
Merged via squash.
Thanks @huntharo! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d7b32b184
ℹ️ 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".
| request: PendingPluginBindingRequest; | ||
| }): void { | ||
| // Keep platform interaction acks fast even if the plugin does slow post-bind work. | ||
| queueMicrotask(() => { |
There was a problem hiding this comment.
Schedule resolution callbacks after caller continuation
Using queueMicrotask here still keeps plugin callback execution on the interaction-ack critical path: Discord/Telegram/Slack all await resolvePluginConversationBindingApproval(...) before sending their acknowledgement/update, and microtasks queued inside this function can run before those callers resume. In environments where a plugin handler does synchronous work (or substantial pre-await CPU work), approvals can still miss platform response deadlines even though the bind decision succeeded, so this needs a true off-path dispatch (e.g., macrotask/background queue) rather than a microtask.
Useful? React with 👍 / 👎.
* main: (142 commits) fix(zalouser): fix setup-only onboarding flow (openclaw#49219) CI: add built plugin singleton smoke (openclaw#48710) update contributing focus areas docs(providers): clarify provider capabilities vs public capability model docs(refactor): align plugin SDK plan with public capability model docs(cli): add plugins inspect command reference docs(plugins): document public capability model, plugin shapes, and inspection Plugins: internalize diagnostics OTel imports Plugins: internalize diffs SDK imports Plugins: internalize more extension SDK imports Plugins: add local extension API barrels Plugins: add inspect matrix and trim export Plugins: add inspect command and capability report fix(telegram): unify transport fallback chain (openclaw#49148) Plugins: add binding resolution callbacks (openclaw#48678) fix(gateway): clear trusted-proxy control ui scopes refactor: narrow extension public seams test: stabilize memory async search close docs(hooks): clarify trust model and audit guidance feat(mattermost): add retry logic and timeout handling for DM channel creation (openclaw#42398) ...
Merged via squash. Prepared head SHA: 6d7b32b Co-authored-by: huntharo <[email protected]> Co-authored-by: huntharo <[email protected]> Reviewed-by: @huntharo
Merged via squash. Prepared head SHA: 6d7b32b Co-authored-by: huntharo <[email protected]> Co-authored-by: huntharo <[email protected]> Reviewed-by: @huntharo
Merged via squash. Prepared head SHA: 6d7b32b Co-authored-by: huntharo <[email protected]> Co-authored-by: huntharo <[email protected]> Reviewed-by: @huntharo
Merged via squash. Prepared head SHA: 6d7b32b 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:
onConversationBindingResolved(...)that fires on both approval and denial, with the resolved decision and request conversation metadata.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
Bound plugins can now react immediately when a bind approval is resolved:
Security Impact (required)
No)No)No)No)No)Yes, explain risk + mitigation:Repro + Verification
Environment
Steps
Expected
Actual
onConversationBindingResolved(...)for both approve and deny, with regression coverage.Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
pnpm test -- src/plugins/conversation-binding.test.tspnpm buildReview 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)
d6a1ee89bbsrc/plugins/types.ts,src/plugins/registry.ts,src/plugins/conversation-binding.tsRisks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.