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",