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
This commit is contained in:
Harold Hunt 2026-03-17 13:11:08 -04:00 committed by GitHub
parent ccf16cd889
commit 272d6ed24b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 169 additions and 2 deletions

View File

@ -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

View File

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

View File

@ -143,6 +143,18 @@ async function resolveRequestedBinding(request: PluginBindingRequest) {
throw new Error("expected pending or bound bind result");
}
async function flushMicrotasks(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
function createDeferredVoid(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
const promise = new Promise<void>((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",

View File

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