From 272d6ed24b09e19abcbe5b8cf63aebe0c79030a5 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Tue, 17 Mar 2026 13:11:08 -0400 Subject: [PATCH] Plugins: add binding resolution callbacks (#48678) Merged via squash. Prepared head SHA: 6d7b32b1849cae1001e581eb6f53b79594dff9b4 Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + docs/tools/plugin.md | 36 +++++++ src/plugins/conversation-binding.test.ts | 116 +++++++++++++++++++++++ src/plugins/conversation-binding.ts | 18 +++- 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fda1fff462f..53114cb9d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. - Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. - Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor. +- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. ### Breaking diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a723978bdc7..9f39b7d02bf 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -69,6 +69,42 @@ OpenClaw resolves known Claude marketplace names from `~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit marketplace source with `--marketplace`. +## Conversation binding callbacks + +Plugins that bind a conversation can now react when an approval is resolved. + +Use `api.onConversationBindingResolved(...)` to receive a callback after a bind +request is approved or denied: + +```ts +export default { + id: "my-plugin", + register(api) { + api.onConversationBindingResolved(async (event) => { + if (event.status === "approved") { + // A binding now exists for this plugin + conversation. + console.log(event.binding?.conversationId); + return; + } + + // The request was denied; clear any local pending state. + console.log(event.request.conversation.conversationId); + }); + }, +}; +``` + +Callback payload fields: + +- `status`: `"approved"` or `"denied"` +- `decision`: `"allow-once"`, `"allow-always"`, or `"deny"` +- `binding`: the resolved binding for approved requests +- `request`: the original request summary, detach hint, sender id, and + conversation metadata + +This callback is notification-only. It does not change who is allowed to bind a +conversation, and it runs after core approval handling finishes. + ## Architecture OpenClaw's plugin system has four layers: diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index d3b88697a59..fe01ed3beed 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -143,6 +143,18 @@ async function resolveRequestedBinding(request: PluginBindingRequest) { throw new Error("expected pending or bound bind result"); } +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +function createDeferredVoid(): { promise: Promise; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + describe("plugin conversation binding approvals", () => { beforeEach(() => { sessionBindingState.reset(); @@ -406,6 +418,7 @@ describe("plugin conversation binding approvals", () => { }); expect(approved.status).toBe("approved"); + await flushMicrotasks(); expect(onResolved).toHaveBeenCalledWith({ status: "approved", binding: expect.objectContaining({ @@ -464,6 +477,7 @@ describe("plugin conversation binding approvals", () => { }); expect(denied.status).toBe("denied"); + await flushMicrotasks(); expect(onResolved).toHaveBeenCalledWith({ status: "denied", binding: undefined, @@ -481,6 +495,108 @@ describe("plugin conversation binding approvals", () => { }); }); + it("does not wait for an approved bind callback before returning", async () => { + const registry = createEmptyPluginRegistry(); + const callbackGate = createDeferredVoid(); + const onResolved = vi.fn(async () => callbackGate.promise); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-slow-approve", + handler: onResolved, + source: "/plugins/callback-slow-approve/index.ts", + rootDir: "/plugins/callback-slow-approve", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-slow-approve", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:slow-approve", + }, + binding: { summary: "Bind this conversation to Codex thread slow-approve." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + let settled = false; + const resolutionPromise = resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }).then((result) => { + settled = true; + return result; + }); + + await flushMicrotasks(); + + expect(settled).toBe(true); + expect(onResolved).toHaveBeenCalledTimes(1); + + callbackGate.resolve(); + const approved = await resolutionPromise; + expect(approved.status).toBe("approved"); + }); + + it("does not wait for a denied bind callback before returning", async () => { + const registry = createEmptyPluginRegistry(); + const callbackGate = createDeferredVoid(); + const onResolved = vi.fn(async () => callbackGate.promise); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: "/plugins/callback-slow-deny", + handler: onResolved, + source: "/plugins/callback-slow-deny/index.ts", + rootDir: "/plugins/callback-slow-deny", + }); + setActivePluginRegistry(registry); + + const request = await requestPluginConversationBinding({ + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-slow-deny", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "slow-deny", + }, + binding: { summary: "Bind this conversation to Codex thread slow-deny." }, + }); + + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + let settled = false; + const resolutionPromise = resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "deny", + senderId: "user-1", + }).then((result) => { + settled = true; + return result; + }); + + await flushMicrotasks(); + + expect(settled).toBe(true); + expect(onResolved).toHaveBeenCalledTimes(1); + + callbackGate.resolve(); + const denied = await resolutionPromise; + expect(denied.status).toBe("denied"); + }); + it("returns and detaches only bindings owned by the requesting plugin root", async () => { const request = await requestPluginConversationBinding({ pluginId: "codex", diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index 283e6c3d71f..aef5ec92b40 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -722,7 +722,7 @@ export async function resolvePluginConversationBindingApproval(params: { } pendingRequests.delete(params.approvalId); if (params.decision === "deny") { - await notifyPluginConversationBindingResolved({ + dispatchPluginConversationBindingResolved({ status: "denied", decision: "deny", request, @@ -755,7 +755,7 @@ export async function resolvePluginConversationBindingApproval(params: { log.info( `plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`, ); - await notifyPluginConversationBindingResolved({ + dispatchPluginConversationBindingResolved({ status: "approved", binding, decision: params.decision, @@ -769,6 +769,20 @@ export async function resolvePluginConversationBindingApproval(params: { }; } +function dispatchPluginConversationBindingResolved(params: { + status: "approved" | "denied"; + binding?: PluginConversationBinding; + decision: PluginConversationBindingResolutionDecision; + request: PendingPluginBindingRequest; +}): void { + // Keep platform interaction acks fast even if the plugin does slow post-bind work. + queueMicrotask(() => { + void notifyPluginConversationBindingResolved(params).catch((error) => { + log.warn(`plugin binding resolved dispatch failed: ${String(error)}`); + }); + }); +} + async function notifyPluginConversationBindingResolved(params: { status: "approved" | "denied"; binding?: PluginConversationBinding;