mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 14:00:51 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user