Skip to content

Commit 272d6ed

Browse files
authored
Plugins: add binding resolution callbacks (#48678)
Merged via squash. Prepared head SHA: 6d7b32b Co-authored-by: huntharo <[email protected]> Co-authored-by: huntharo <[email protected]> Reviewed-by: @huntharo
1 parent ccf16cd commit 272d6ed

File tree

4 files changed

+169
-2
lines changed

4 files changed

+169
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
3636
- Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant.
3737
- 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.
3838
- 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.
39+
- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo.
3940

4041
### Breaking
4142

docs/tools/plugin.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,42 @@ OpenClaw resolves known Claude marketplace names from
6969
`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit
7070
marketplace source with `--marketplace`.
7171

72+
## Conversation binding callbacks
73+
74+
Plugins that bind a conversation can now react when an approval is resolved.
75+
76+
Use `api.onConversationBindingResolved(...)` to receive a callback after a bind
77+
request is approved or denied:
78+
79+
```ts
80+
export default {
81+
id: "my-plugin",
82+
register(api) {
83+
api.onConversationBindingResolved(async (event) => {
84+
if (event.status === "approved") {
85+
// A binding now exists for this plugin + conversation.
86+
console.log(event.binding?.conversationId);
87+
return;
88+
}
89+
90+
// The request was denied; clear any local pending state.
91+
console.log(event.request.conversation.conversationId);
92+
});
93+
},
94+
};
95+
```
96+
97+
Callback payload fields:
98+
99+
- `status`: `"approved"` or `"denied"`
100+
- `decision`: `"allow-once"`, `"allow-always"`, or `"deny"`
101+
- `binding`: the resolved binding for approved requests
102+
- `request`: the original request summary, detach hint, sender id, and
103+
conversation metadata
104+
105+
This callback is notification-only. It does not change who is allowed to bind a
106+
conversation, and it runs after core approval handling finishes.
107+
72108
## Architecture
73109

74110
OpenClaw's plugin system has four layers:

src/plugins/conversation-binding.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,18 @@ async function resolveRequestedBinding(request: PluginBindingRequest) {
143143
throw new Error("expected pending or bound bind result");
144144
}
145145

146+
async function flushMicrotasks(): Promise<void> {
147+
await new Promise<void>((resolve) => setImmediate(resolve));
148+
}
149+
150+
function createDeferredVoid(): { promise: Promise<void>; resolve: () => void } {
151+
let resolve = () => {};
152+
const promise = new Promise<void>((innerResolve) => {
153+
resolve = innerResolve;
154+
});
155+
return { promise, resolve };
156+
}
157+
146158
describe("plugin conversation binding approvals", () => {
147159
beforeEach(() => {
148160
sessionBindingState.reset();
@@ -406,6 +418,7 @@ describe("plugin conversation binding approvals", () => {
406418
});
407419

408420
expect(approved.status).toBe("approved");
421+
await flushMicrotasks();
409422
expect(onResolved).toHaveBeenCalledWith({
410423
status: "approved",
411424
binding: expect.objectContaining({
@@ -464,6 +477,7 @@ describe("plugin conversation binding approvals", () => {
464477
});
465478

466479
expect(denied.status).toBe("denied");
480+
await flushMicrotasks();
467481
expect(onResolved).toHaveBeenCalledWith({
468482
status: "denied",
469483
binding: undefined,
@@ -481,6 +495,108 @@ describe("plugin conversation binding approvals", () => {
481495
});
482496
});
483497

498+
it("does not wait for an approved bind callback before returning", async () => {
499+
const registry = createEmptyPluginRegistry();
500+
const callbackGate = createDeferredVoid();
501+
const onResolved = vi.fn(async () => callbackGate.promise);
502+
registry.conversationBindingResolvedHandlers.push({
503+
pluginId: "codex",
504+
pluginRoot: "/plugins/callback-slow-approve",
505+
handler: onResolved,
506+
source: "/plugins/callback-slow-approve/index.ts",
507+
rootDir: "/plugins/callback-slow-approve",
508+
});
509+
setActivePluginRegistry(registry);
510+
511+
const request = await requestPluginConversationBinding({
512+
pluginId: "codex",
513+
pluginName: "Codex App Server",
514+
pluginRoot: "/plugins/callback-slow-approve",
515+
requestedBySenderId: "user-1",
516+
conversation: {
517+
channel: "discord",
518+
accountId: "isolated",
519+
conversationId: "channel:slow-approve",
520+
},
521+
binding: { summary: "Bind this conversation to Codex thread slow-approve." },
522+
});
523+
524+
expect(request.status).toBe("pending");
525+
if (request.status !== "pending") {
526+
throw new Error("expected pending bind request");
527+
}
528+
529+
let settled = false;
530+
const resolutionPromise = resolvePluginConversationBindingApproval({
531+
approvalId: request.approvalId,
532+
decision: "allow-once",
533+
senderId: "user-1",
534+
}).then((result) => {
535+
settled = true;
536+
return result;
537+
});
538+
539+
await flushMicrotasks();
540+
541+
expect(settled).toBe(true);
542+
expect(onResolved).toHaveBeenCalledTimes(1);
543+
544+
callbackGate.resolve();
545+
const approved = await resolutionPromise;
546+
expect(approved.status).toBe("approved");
547+
});
548+
549+
it("does not wait for a denied bind callback before returning", async () => {
550+
const registry = createEmptyPluginRegistry();
551+
const callbackGate = createDeferredVoid();
552+
const onResolved = vi.fn(async () => callbackGate.promise);
553+
registry.conversationBindingResolvedHandlers.push({
554+
pluginId: "codex",
555+
pluginRoot: "/plugins/callback-slow-deny",
556+
handler: onResolved,
557+
source: "/plugins/callback-slow-deny/index.ts",
558+
rootDir: "/plugins/callback-slow-deny",
559+
});
560+
setActivePluginRegistry(registry);
561+
562+
const request = await requestPluginConversationBinding({
563+
pluginId: "codex",
564+
pluginName: "Codex App Server",
565+
pluginRoot: "/plugins/callback-slow-deny",
566+
requestedBySenderId: "user-1",
567+
conversation: {
568+
channel: "telegram",
569+
accountId: "default",
570+
conversationId: "slow-deny",
571+
},
572+
binding: { summary: "Bind this conversation to Codex thread slow-deny." },
573+
});
574+
575+
expect(request.status).toBe("pending");
576+
if (request.status !== "pending") {
577+
throw new Error("expected pending bind request");
578+
}
579+
580+
let settled = false;
581+
const resolutionPromise = resolvePluginConversationBindingApproval({
582+
approvalId: request.approvalId,
583+
decision: "deny",
584+
senderId: "user-1",
585+
}).then((result) => {
586+
settled = true;
587+
return result;
588+
});
589+
590+
await flushMicrotasks();
591+
592+
expect(settled).toBe(true);
593+
expect(onResolved).toHaveBeenCalledTimes(1);
594+
595+
callbackGate.resolve();
596+
const denied = await resolutionPromise;
597+
expect(denied.status).toBe("denied");
598+
});
599+
484600
it("returns and detaches only bindings owned by the requesting plugin root", async () => {
485601
const request = await requestPluginConversationBinding({
486602
pluginId: "codex",

src/plugins/conversation-binding.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ export async function resolvePluginConversationBindingApproval(params: {
722722
}
723723
pendingRequests.delete(params.approvalId);
724724
if (params.decision === "deny") {
725-
await notifyPluginConversationBindingResolved({
725+
dispatchPluginConversationBindingResolved({
726726
status: "denied",
727727
decision: "deny",
728728
request,
@@ -755,7 +755,7 @@ export async function resolvePluginConversationBindingApproval(params: {
755755
log.info(
756756
`plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
757757
);
758-
await notifyPluginConversationBindingResolved({
758+
dispatchPluginConversationBindingResolved({
759759
status: "approved",
760760
binding,
761761
decision: params.decision,
@@ -769,6 +769,20 @@ export async function resolvePluginConversationBindingApproval(params: {
769769
};
770770
}
771771

772+
function dispatchPluginConversationBindingResolved(params: {
773+
status: "approved" | "denied";
774+
binding?: PluginConversationBinding;
775+
decision: PluginConversationBindingResolutionDecision;
776+
request: PendingPluginBindingRequest;
777+
}): void {
778+
// Keep platform interaction acks fast even if the plugin does slow post-bind work.
779+
queueMicrotask(() => {
780+
void notifyPluginConversationBindingResolved(params).catch((error) => {
781+
log.warn(`plugin binding resolved dispatch failed: ${String(error)}`);
782+
});
783+
});
784+
}
785+
772786
async function notifyPluginConversationBindingResolved(params: {
773787
status: "approved" | "denied";
774788
binding?: PluginConversationBinding;

0 commit comments

Comments
 (0)