ACP: harden startup and move configured routing behind plugin seams (#48197)

* ACPX: keep plugin-local runtime installs out of dist

* Gateway: harden ACP startup and service PATH

* ACP: reinitialize error-state configured bindings

* ACP: classify pre-turn runtime failures as session init failures

* Plugins: move configured ACP routing behind channel seams

* Telegram tests: align startup probe assertions after rebase

* Discord: harden ACP configured binding recovery

* ACP: recover Discord bindings after stale runtime exits

* ACPX: replace dead sessions during ensure

* Discord: harden ACP binding recovery

* Discord: fix review follow-ups

* ACP bindings: load channel snapshots across workspaces

* ACP bindings: cache snapshot channel plugin resolution

* Experiments: add ACP pluginification holy grail plan

* Experiments: rename ACP pluginification plan doc

* Experiments: drop old ACP pluginification doc path

* ACP: move configured bindings behind plugin services

* Experiments: update bindings capability architecture plan

* Bindings: isolate configured binding routing and targets

* Discord tests: fix runtime env helper path

* Tests: fix channel binding CI regressions

* Tests: normalize ACP workspace assertion on Windows

* Bindings: isolate configured binding registry

* Bindings: finish configured binding cleanup

* Bindings: finish generic cleanup

* Bindings: align runtime approval callbacks

* ACP: delete residual bindings barrel

* Bindings: restore legacy compatibility

* Revert "Bindings: restore legacy compatibility"

This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe.

* Tests: drop ACP route legacy helper names

* Discord/ACP: fix binding regressions

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
Bob
2026-03-17 17:27:52 +01:00
committed by GitHub
parent 8139f83175
commit ea15819ecf
102 changed files with 6606 additions and 1199 deletions

View File

@@ -7,6 +7,8 @@ import type {
SessionBindingAdapter,
SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";
import { createEmptyPluginRegistry } from "./registry.js";
import { setActivePluginRegistry } from "./runtime.js";
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-"));
const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json");
@@ -145,6 +147,7 @@ describe("plugin conversation binding approvals", () => {
beforeEach(() => {
sessionBindingState.reset();
__testing.reset();
setActivePluginRegistry(createEmptyPluginRegistry());
fs.rmSync(approvalsPath, { force: true });
unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" });
unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" });
@@ -366,6 +369,118 @@ describe("plugin conversation binding approvals", () => {
expect(currentBinding?.detachHint).toBe("/codex_detach");
});
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",
},
},
});
});
it("notifies the owning plugin when a bind approval is denied", async () => {
const registry = createEmptyPluginRegistry();
const onResolved = vi.fn(async () => undefined);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
pluginRoot: "/plugins/callback-deny",
handler: onResolved,
source: "/plugins/callback-deny/index.ts",
rootDir: "/plugins/callback-deny",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-deny",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
},
binding: { summary: "Bind this conversation to Codex thread deny." },
});
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
const denied = await resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: "deny",
senderId: "user-1",
});
expect(denied.status).toBe("denied");
expect(onResolved).toHaveBeenCalledWith({
status: "denied",
binding: undefined,
decision: "deny",
request: {
summary: "Bind this conversation to Codex thread deny.",
detachHint: undefined,
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
},
},
});
});
it("returns and detaches only bindings owned by the requesting plugin root", async () => {
const request = await requestPluginConversationBinding({
pluginId: "codex",

View File

@@ -2,15 +2,20 @@ import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { ReplyPayload } from "../auto-reply/types.js";
import {
createConversationBindingRecord,
resolveConversationBindingRecord,
unbindConversationBindingRecord,
} from "../bindings/records.js";
import { expandHomePrefix } from "../infra/home-dir.js";
import { writeJsonAtomic } from "../infra/json-files.js";
import {
getSessionBindingService,
type ConversationRef,
} from "../infra/outbound/session-binding-service.js";
import { type ConversationRef } from "../infra/outbound/session-binding-service.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getActivePluginRegistry } from "./runtime.js";
import type {
PluginConversationBinding,
PluginConversationBindingResolvedEvent,
PluginConversationBindingResolutionDecision,
PluginConversationBindingRequestParams,
PluginConversationBindingRequestResult,
} from "./types.js";
@@ -26,7 +31,9 @@ const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [
"openclaw-codex-app-server:thread:",
] as const;
type PluginBindingApprovalDecision = "allow-once" | "allow-always" | "deny";
// Runtime plugin conversation bindings are approval-driven and distinct from
// configured channel bindings compiled from config.
type PluginBindingApprovalDecision = PluginConversationBindingResolutionDecision;
type PluginBindingApprovalEntry = {
pluginRoot: string;
@@ -87,7 +94,7 @@ type PluginBindingResolveResult =
status: "approved";
binding: PluginConversationBinding;
request: PendingPluginBindingRequest;
decision: PluginBindingApprovalDecision;
decision: Exclude<PluginBindingApprovalDecision, "deny">;
}
| {
status: "denied";
@@ -423,7 +430,7 @@ async function bindConversationNow(params: {
accountId: ref.accountId,
conversationId: ref.conversationId,
});
const record = await getSessionBindingService().bind({
const record = await createConversationBindingRecord({
targetSessionKey,
targetKind: "session",
conversation: ref,
@@ -574,7 +581,7 @@ export async function requestPluginConversationBinding(params: {
}): Promise<PluginConversationBindingRequestResult> {
const conversation = normalizeConversation(params.conversation);
const ref = toConversationRef(conversation);
const existing = getSessionBindingService().resolveByConversation(ref);
const existing = resolveConversationBindingRecord(ref);
const existingPluginBinding = toPluginConversationBinding(existing);
const existingLegacyPluginBinding = isLegacyPluginBindingRecord({
record: existing,
@@ -665,9 +672,7 @@ export async function getCurrentPluginConversationBinding(params: {
pluginRoot: string;
conversation: PluginBindingConversation;
}): Promise<PluginConversationBinding | null> {
const record = getSessionBindingService().resolveByConversation(
toConversationRef(params.conversation),
);
const record = resolveConversationBindingRecord(toConversationRef(params.conversation));
const binding = toPluginConversationBinding(record);
if (!binding || binding.pluginRoot !== params.pluginRoot) {
return null;
@@ -684,12 +689,12 @@ export async function detachPluginConversationBinding(params: {
conversation: PluginBindingConversation;
}): Promise<{ removed: boolean }> {
const ref = toConversationRef(params.conversation);
const record = getSessionBindingService().resolveByConversation(ref);
const record = resolveConversationBindingRecord(ref);
const binding = toPluginConversationBinding(record);
if (!binding || binding.pluginRoot !== params.pluginRoot) {
return { removed: false };
}
await getSessionBindingService().unbind({
await unbindConversationBindingRecord({
bindingId: binding.bindingId,
reason: "plugin-detach",
});
@@ -717,6 +722,11 @@ export async function resolvePluginConversationBindingApproval(params: {
}
pendingRequests.delete(params.approvalId);
if (params.decision === "deny") {
await notifyPluginConversationBindingResolved({
status: "denied",
decision: "deny",
request,
});
log.info(
`plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
);
@@ -745,6 +755,12 @@ 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({
status: "approved",
binding,
decision: params.decision,
request,
});
return {
status: "approved",
binding,
@@ -753,6 +769,42 @@ export async function resolvePluginConversationBindingApproval(params: {
};
}
async function notifyPluginConversationBindingResolved(params: {
status: "approved" | "denied";
binding?: PluginConversationBinding;
decision: PluginConversationBindingResolutionDecision;
request: PendingPluginBindingRequest;
}): Promise<void> {
const registrations = getActivePluginRegistry()?.conversationBindingResolvedHandlers ?? [];
for (const registration of registrations) {
if (registration.pluginId !== params.request.pluginId) {
continue;
}
const registeredRoot = registration.pluginRoot?.trim();
if (registeredRoot && registeredRoot !== params.request.pluginRoot) {
continue;
}
try {
const event: PluginConversationBindingResolvedEvent = {
status: params.status,
binding: params.binding,
decision: params.decision,
request: {
summary: params.request.summary,
detachHint: params.request.detachHint,
requestedBySenderId: params.request.requestedBySenderId,
conversation: params.request.conversation,
},
};
await registration.handler(event);
} catch (error) {
log.warn(
`plugin binding resolved callback failed plugin=${registration.pluginId} root=${registration.pluginRoot ?? "<none>"}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string {
if (params.status === "expired") {
return "That plugin bind approval expired. Retry the bind command.";

View File

@@ -28,6 +28,7 @@ import type {
OpenClawPluginChannelRegistration,
OpenClawPluginCliRegistrar,
OpenClawPluginCommandDefinition,
PluginConversationBindingResolvedEvent,
OpenClawPluginHttpRouteAuth,
OpenClawPluginHttpRouteMatch,
OpenClawPluginHttpRouteHandler,
@@ -147,6 +148,15 @@ export type PluginCommandRegistration = {
rootDir?: string;
};
export type PluginConversationBindingResolvedHandlerRegistration = {
pluginId: string;
pluginName?: string;
pluginRoot?: string;
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>;
source: string;
rootDir?: string;
};
export type PluginRecord = {
id: string;
name: string;
@@ -199,6 +209,7 @@ export type PluginRegistry = {
cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[];
commands: PluginCommandRegistration[];
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
diagnostics: PluginDiagnostic[];
};
@@ -247,6 +258,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
};
}
@@ -829,6 +841,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
} as TypedPluginHookRegistration);
};
const registerConversationBindingResolvedHandler = (
record: PluginRecord,
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>,
) => {
registry.conversationBindingResolvedHandlers.push({
pluginId: record.id,
pluginName: record.name,
pluginRoot: record.rootDir,
handler,
source: record.source,
rootDir: record.rootDir,
});
};
const normalizeLogger = (logger: PluginLogger): PluginLogger => ({
info: logger.info,
warn: logger.warn,
@@ -942,6 +968,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
}
: () => {},
onConversationBindingResolved:
registrationMode === "full"
? (handler) => registerConversationBindingResolvedHandler(record, handler)
: () => {},
registerCommand:
registrationMode === "full" ? (command) => registerCommand(record, command) : () => {},
registerContextEngine: (id, factory) => {

View File

@@ -940,6 +940,8 @@ export type PluginConversationBindingRequestParams = {
detachHint?: string;
};
export type PluginConversationBindingResolutionDecision = "allow-once" | "allow-always" | "deny";
export type PluginConversationBinding = {
bindingId: string;
pluginId: string;
@@ -970,6 +972,24 @@ export type PluginConversationBindingRequestResult =
message: string;
};
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;
};
};
};
/**
* Result returned by a plugin command handler.
*/
@@ -1256,6 +1276,9 @@ export type OpenClawPluginApi = {
registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void;
registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void;
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
onConversationBindingResolved: (
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>,
) => void;
/**
* Register a custom command that bypasses the LLM agent.
* Plugin commands are processed before built-in commands and before agent invocation.