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:
parent
ccf16cd889
commit
272d6ed24b
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user