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

@@ -603,137 +603,164 @@ export class AcpSessionManager {
}
await this.evictIdleRuntimeHandles({ cfg: input.cfg });
await this.withSessionActor(sessionKey, async () => {
const resolution = this.resolveSession({
cfg: input.cfg,
sessionKey,
});
const resolvedMeta = requireReadySessionMeta(resolution);
const {
runtime,
handle: ensuredHandle,
meta: ensuredMeta,
} = await this.ensureRuntimeHandle({
cfg: input.cfg,
sessionKey,
meta: resolvedMeta,
});
let handle = ensuredHandle;
const meta = ensuredMeta;
await this.applyRuntimeControls({
sessionKey,
runtime,
handle,
meta,
});
const turnStartedAt = Date.now();
const actorKey = normalizeActorKey(sessionKey);
await this.setSessionState({
cfg: input.cfg,
sessionKey,
state: "running",
clearLastError: true,
});
const internalAbortController = new AbortController();
const onCallerAbort = () => {
internalAbortController.abort();
};
if (input.signal?.aborted) {
internalAbortController.abort();
} else if (input.signal) {
input.signal.addEventListener("abort", onCallerAbort, { once: true });
}
const activeTurn: ActiveTurnState = {
runtime,
handle,
abortController: internalAbortController,
};
this.activeTurnBySession.set(actorKey, activeTurn);
let streamError: AcpRuntimeError | null = null;
try {
const combinedSignal =
input.signal && typeof AbortSignal.any === "function"
? AbortSignal.any([input.signal, internalAbortController.signal])
: internalAbortController.signal;
for await (const event of runtime.runTurn({
handle,
text: input.text,
attachments: input.attachments,
mode: input.mode,
requestId: input.requestId,
signal: combinedSignal,
})) {
if (event.type === "error") {
streamError = new AcpRuntimeError(
normalizeAcpErrorCode(event.code),
event.message?.trim() || "ACP turn failed before completion.",
);
}
if (input.onEvent) {
await input.onEvent(event);
}
}
if (streamError) {
throw streamError;
}
this.recordTurnCompletion({
startedAt: turnStartedAt,
});
await this.setSessionState({
for (let attempt = 0; attempt < 2; attempt += 1) {
const resolution = this.resolveSession({
cfg: input.cfg,
sessionKey,
state: "idle",
clearLastError: true,
});
} catch (error) {
const acpError = toAcpRuntimeError({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP turn failed before completion.",
});
this.recordTurnCompletion({
startedAt: turnStartedAt,
errorCode: acpError.code,
});
await this.setSessionState({
cfg: input.cfg,
sessionKey,
state: "error",
lastError: acpError.message,
});
throw acpError;
} finally {
if (input.signal) {
input.signal.removeEventListener("abort", onCallerAbort);
}
if (this.activeTurnBySession.get(actorKey) === activeTurn) {
this.activeTurnBySession.delete(actorKey);
}
if (meta.mode !== "oneshot") {
({ handle } = await this.reconcileRuntimeSessionIdentifiers({
const resolvedMeta = requireReadySessionMeta(resolution);
let runtime: AcpRuntime | undefined;
let handle: AcpRuntimeHandle | undefined;
let meta: SessionAcpMeta | undefined;
let activeTurn: ActiveTurnState | undefined;
let internalAbortController: AbortController | undefined;
let onCallerAbort: (() => void) | undefined;
let activeTurnStarted = false;
let sawTurnOutput = false;
let retryFreshHandle = false;
try {
const ensured = await this.ensureRuntimeHandle({
cfg: input.cfg,
sessionKey,
meta: resolvedMeta,
});
runtime = ensured.runtime;
handle = ensured.handle;
meta = ensured.meta;
await this.applyRuntimeControls({
sessionKey,
runtime,
handle,
meta,
failOnStatusError: false,
}));
}
if (meta.mode === "oneshot") {
try {
await runtime.close({
handle,
reason: "oneshot-complete",
});
} catch (error) {
logVerbose(`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`);
} finally {
this.clearCachedRuntimeState(sessionKey);
});
await this.setSessionState({
cfg: input.cfg,
sessionKey,
state: "running",
clearLastError: true,
});
internalAbortController = new AbortController();
onCallerAbort = () => {
internalAbortController?.abort();
};
if (input.signal?.aborted) {
internalAbortController.abort();
} else if (input.signal) {
input.signal.addEventListener("abort", onCallerAbort, { once: true });
}
activeTurn = {
runtime,
handle,
abortController: internalAbortController,
};
this.activeTurnBySession.set(actorKey, activeTurn);
activeTurnStarted = true;
let streamError: AcpRuntimeError | null = null;
const combinedSignal =
input.signal && typeof AbortSignal.any === "function"
? AbortSignal.any([input.signal, internalAbortController.signal])
: internalAbortController.signal;
for await (const event of runtime.runTurn({
handle,
text: input.text,
attachments: input.attachments,
mode: input.mode,
requestId: input.requestId,
signal: combinedSignal,
})) {
if (event.type === "error") {
streamError = new AcpRuntimeError(
normalizeAcpErrorCode(event.code),
event.message?.trim() || "ACP turn failed before completion.",
);
} else if (event.type === "text_delta" || event.type === "tool_call") {
sawTurnOutput = true;
}
if (input.onEvent) {
await input.onEvent(event);
}
}
if (streamError) {
throw streamError;
}
this.recordTurnCompletion({
startedAt: turnStartedAt,
});
await this.setSessionState({
cfg: input.cfg,
sessionKey,
state: "idle",
clearLastError: true,
});
return;
} catch (error) {
const acpError = toAcpRuntimeError({
error,
fallbackCode: activeTurnStarted ? "ACP_TURN_FAILED" : "ACP_SESSION_INIT_FAILED",
fallbackMessage: activeTurnStarted
? "ACP turn failed before completion."
: "Could not initialize ACP session runtime.",
});
retryFreshHandle = this.shouldRetryTurnWithFreshHandle({
attempt,
sessionKey,
error: acpError,
sawTurnOutput,
});
if (retryFreshHandle) {
continue;
}
this.recordTurnCompletion({
startedAt: turnStartedAt,
errorCode: acpError.code,
});
await this.setSessionState({
cfg: input.cfg,
sessionKey,
state: "error",
lastError: acpError.message,
});
throw acpError;
} finally {
if (input.signal && onCallerAbort) {
input.signal.removeEventListener("abort", onCallerAbort);
}
if (activeTurn && this.activeTurnBySession.get(actorKey) === activeTurn) {
this.activeTurnBySession.delete(actorKey);
}
if (!retryFreshHandle && runtime && handle && meta && meta.mode !== "oneshot") {
({ handle } = await this.reconcileRuntimeSessionIdentifiers({
cfg: input.cfg,
sessionKey,
runtime,
handle,
meta,
failOnStatusError: false,
}));
}
if (!retryFreshHandle && runtime && handle && meta && meta.mode === "oneshot") {
try {
await runtime.close({
handle,
reason: "oneshot-complete",
});
} catch (error) {
logVerbose(
`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`,
);
} finally {
this.clearCachedRuntimeState(sessionKey);
}
}
}
if (retryFreshHandle) {
continue;
}
}
});
@@ -864,7 +891,9 @@ export class AcpSessionManager {
});
if (
input.allowBackendUnavailable &&
(acpError.code === "ACP_BACKEND_MISSING" || acpError.code === "ACP_BACKEND_UNAVAILABLE")
(acpError.code === "ACP_BACKEND_MISSING" ||
acpError.code === "ACP_BACKEND_UNAVAILABLE" ||
this.isRecoverableAcpxExitError(acpError.message))
) {
// Treat unavailable backends as terminal for this cached handle so it
// cannot continue counting against maxConcurrentSessions.
@@ -916,7 +945,17 @@ export class AcpSessionManager {
const agentMatches = cached.agent === agent;
const modeMatches = cached.mode === mode;
const cwdMatches = (cached.cwd ?? "") === (cwd ?? "");
if (backendMatches && agentMatches && modeMatches && cwdMatches) {
if (
backendMatches &&
agentMatches &&
modeMatches &&
cwdMatches &&
(await this.isCachedRuntimeHandleReusable({
sessionKey: params.sessionKey,
runtime: cached.runtime,
handle: cached.handle,
}))
) {
return {
runtime: cached.runtime,
handle: cached.handle,
@@ -1020,6 +1059,49 @@ export class AcpSessionManager {
};
}
private async isCachedRuntimeHandleReusable(params: {
sessionKey: string;
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
}): Promise<boolean> {
if (!params.runtime.getStatus) {
return true;
}
try {
const status = await params.runtime.getStatus({
handle: params.handle,
});
if (this.isRuntimeStatusUnavailable(status)) {
this.clearCachedRuntimeState(params.sessionKey);
logVerbose(
`acp-manager: evicting cached runtime handle for ${params.sessionKey} after unhealthy status probe: ${status.summary ?? "status unavailable"}`,
);
return false;
}
return true;
} catch (error) {
this.clearCachedRuntimeState(params.sessionKey);
logVerbose(
`acp-manager: evicting cached runtime handle for ${params.sessionKey} after status probe failed: ${String(error)}`,
);
return false;
}
}
private isRuntimeStatusUnavailable(status: AcpRuntimeStatus | undefined): boolean {
if (!status) {
return false;
}
const detailsStatus =
typeof status.details?.status === "string" ? status.details.status.trim().toLowerCase() : "";
if (detailsStatus === "dead" || detailsStatus === "no-session") {
return true;
}
const summaryMatch = status.summary?.match(/\bstatus=([^\s]+)/i);
const summaryStatus = summaryMatch?.[1]?.trim().toLowerCase() ?? "";
return summaryStatus === "dead" || summaryStatus === "no-session";
}
private async persistRuntimeOptions(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -1103,6 +1185,29 @@ export class AcpSessionManager {
this.errorCountsByCode.set(normalized, (this.errorCountsByCode.get(normalized) ?? 0) + 1);
}
private shouldRetryTurnWithFreshHandle(params: {
attempt: number;
sessionKey: string;
error: AcpRuntimeError;
sawTurnOutput: boolean;
}): boolean {
if (params.attempt > 0 || params.sawTurnOutput) {
return false;
}
if (!this.isRecoverableAcpxExitError(params.error.message)) {
return false;
}
this.clearCachedRuntimeState(params.sessionKey);
logVerbose(
`acp-manager: retrying ${params.sessionKey} with a fresh runtime handle after early turn failure: ${params.error.message}`,
);
return true;
}
private isRecoverableAcpxExitError(message: string): boolean {
return /^acpx exited with code \d+/i.test(message.trim());
}
private async evictIdleRuntimeHandles(params: { cfg: OpenClawConfig }): Promise<void> {
const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg);
if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) {

View File

@@ -354,6 +354,52 @@ describe("AcpSessionManager", () => {
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
});
it("re-ensures cached runtime handles when the backend reports the session is dead", async () => {
const runtimeState = createRuntime();
runtimeState.getStatus
.mockResolvedValueOnce({
summary: "status=alive",
details: { status: "alive" },
})
.mockResolvedValueOnce({
summary: "status=dead",
details: { status: "dead" },
})
.mockResolvedValueOnce({
summary: "status=alive",
details: { status: "alive" },
});
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
id: "acpx",
runtime: runtimeState.runtime,
});
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:session-1",
storeSessionKey: "agent:codex:acp:session-1",
acp: readySessionMeta(),
});
const manager = new AcpSessionManager();
await manager.runTurn({
cfg: baseCfg,
sessionKey: "agent:codex:acp:session-1",
text: "first",
mode: "prompt",
requestId: "r1",
});
await manager.runTurn({
cfg: baseCfg,
sessionKey: "agent:codex:acp:session-1",
text: "second",
mode: "prompt",
requestId: "r2",
});
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
expect(runtimeState.getStatus).toHaveBeenCalledTimes(3);
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
});
it("rehydrates runtime handles after a manager restart", async () => {
const runtimeState = createRuntime();
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
@@ -531,6 +577,61 @@ describe("AcpSessionManager", () => {
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
});
it("drops cached runtime handles when close sees a stale acpx process-exit error", async () => {
const runtimeState = createRuntime();
runtimeState.close.mockRejectedValueOnce(new Error("acpx exited with code 1"));
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
id: "acpx",
runtime: runtimeState.runtime,
});
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
return {
sessionKey,
storeSessionKey: sessionKey,
acp: {
...readySessionMeta(),
runtimeSessionName: `runtime:${sessionKey}`,
},
};
});
const limitedCfg = {
acp: {
...baseCfg.acp,
maxConcurrentSessions: 1,
},
} as OpenClawConfig;
const manager = new AcpSessionManager();
await manager.runTurn({
cfg: limitedCfg,
sessionKey: "agent:codex:acp:session-a",
text: "first",
mode: "prompt",
requestId: "r1",
});
const closeResult = await manager.closeSession({
cfg: limitedCfg,
sessionKey: "agent:codex:acp:session-a",
reason: "manual-close",
allowBackendUnavailable: true,
});
expect(closeResult.runtimeClosed).toBe(false);
expect(closeResult.runtimeNotice).toBe("acpx exited with code 1");
await expect(
manager.runTurn({
cfg: limitedCfg,
sessionKey: "agent:codex:acp:session-b",
text: "second",
mode: "prompt",
requestId: "r2",
}),
).resolves.toBeUndefined();
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
});
it("evicts idle cached runtimes before enforcing max concurrent limits", async () => {
vi.useFakeTimers();
try {
@@ -807,6 +908,82 @@ describe("AcpSessionManager", () => {
expect(states.at(-1)).toBe("error");
});
it("marks the session as errored when runtime ensure fails before turn start", async () => {
const runtimeState = createRuntime();
runtimeState.ensureSession.mockRejectedValue(new Error("acpx exited with code 1"));
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
id: "acpx",
runtime: runtimeState.runtime,
});
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:session-1",
storeSessionKey: "agent:codex:acp:session-1",
acp: {
...readySessionMeta(),
state: "running",
},
});
const manager = new AcpSessionManager();
await expect(
manager.runTurn({
cfg: baseCfg,
sessionKey: "agent:codex:acp:session-1",
text: "do work",
mode: "prompt",
requestId: "run-1",
}),
).rejects.toMatchObject({
code: "ACP_SESSION_INIT_FAILED",
message: "acpx exited with code 1",
});
const states = extractStatesFromUpserts();
expect(states).not.toContain("running");
expect(states.at(-1)).toBe("error");
});
it("retries once with a fresh runtime handle after an early acpx exit", async () => {
const runtimeState = createRuntime();
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
id: "acpx",
runtime: runtimeState.runtime,
});
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:session-1",
storeSessionKey: "agent:codex:acp:session-1",
acp: readySessionMeta(),
});
runtimeState.runTurn
.mockImplementationOnce(async function* () {
yield {
type: "error" as const,
message: "acpx exited with code 1",
};
})
.mockImplementationOnce(async function* () {
yield { type: "done" as const };
});
const manager = new AcpSessionManager();
await expect(
manager.runTurn({
cfg: baseCfg,
sessionKey: "agent:codex:acp:session-1",
text: "do work",
mode: "prompt",
requestId: "run-1",
}),
).resolves.toBeUndefined();
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
const states = extractStatesFromUpserts();
expect(states).toContain("running");
expect(states).toContain("idle");
expect(states).not.toContain("error");
});
it("persists runtime mode changes through setSessionRuntimeMode", async () => {
const runtimeState = createRuntime();
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../test/helpers/import-fresh.js";
import type { OpenClawConfig } from "../config/config.js";
const managerMocks = vi.hoisted(() => ({
closeSession: vi.fn(),
initializeSession: vi.fn(),
updateSessionRuntimeOptions: vi.fn(),
}));
const sessionMetaMocks = vi.hoisted(() => ({
readAcpSessionEntry: vi.fn(),
}));
const resolveMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingSpecBySessionKey: vi.fn(),
}));
vi.mock("./control-plane/manager.js", () => ({
getAcpSessionManager: () => ({
closeSession: managerMocks.closeSession,
initializeSession: managerMocks.initializeSession,
updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions,
}),
}));
vi.mock("./runtime/session-meta.js", () => ({
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
}));
vi.mock("./persistent-bindings.resolve.js", () => ({
resolveConfiguredAcpBindingSpecBySessionKey:
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey,
}));
type BindingTargetsModule = typeof import("../channels/plugins/binding-targets.js");
let bindingTargets: BindingTargetsModule;
let bindingTargetsImportScope = 0;
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
agents: {
list: [{ id: "codex" }, { id: "claude" }],
},
} satisfies OpenClawConfig;
beforeEach(async () => {
vi.resetModules();
bindingTargetsImportScope += 1;
bindingTargets = await importFreshModule<BindingTargetsModule>(
import.meta.url,
`../channels/plugins/binding-targets.js?scope=${bindingTargetsImportScope}`,
);
managerMocks.closeSession.mockReset().mockResolvedValue({
runtimeClosed: true,
metaCleared: false,
});
managerMocks.initializeSession.mockReset().mockResolvedValue(undefined);
managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined);
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockReset().mockReturnValue(null);
});
describe("resetConfiguredBindingTargetInPlace", () => {
it("does not resolve configured bindings when ACP metadata already exists", async () => {
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
acp: {
agent: "claude",
mode: "persistent",
backend: "acpx",
runtimeOptions: { cwd: "/home/bob/clawd" },
},
});
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockImplementation(() => {
throw new Error("configured binding resolution should be skipped");
});
const result = await bindingTargets.resetConfiguredBindingTargetInPlace({
cfg: baseCfg,
sessionKey,
reason: "reset",
});
expect(result).toEqual({ ok: true });
expect(resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey).not.toHaveBeenCalled();
expect(managerMocks.closeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
clearMeta: false,
}),
);
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
agent: "claude",
backendId: "acpx",
}),
);
});
});

View File

@@ -8,6 +8,7 @@ import {
buildConfiguredAcpSessionKey,
normalizeText,
type ConfiguredAcpBindingSpec,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.types.js";
import { readAcpSessionEntry } from "./runtime/session-meta.js";
@@ -96,7 +97,7 @@ export async function ensureConfiguredAcpBindingSession(params: {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logVerbose(
`acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
`acp-configured-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
);
return {
ok: false,
@@ -106,6 +107,26 @@ export async function ensureConfiguredAcpBindingSession(params: {
}
}
export async function ensureConfiguredAcpBindingReady(params: {
cfg: OpenClawConfig;
configuredBinding: ResolvedConfiguredAcpBinding | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (!params.configuredBinding) {
return { ok: true };
}
const ensured = await ensureConfiguredAcpBindingSession({
cfg: params.cfg,
spec: params.configuredBinding.spec,
});
if (ensured.ok) {
return { ok: true };
}
return {
ok: false,
error: ensured.error ?? "unknown error",
};
}
export async function resetAcpSessionInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -119,14 +140,17 @@ export async function resetAcpSessionInPlace(params: {
};
}
const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({
cfg: params.cfg,
sessionKey,
});
const meta = readAcpSessionEntry({
cfg: params.cfg,
sessionKey,
})?.acp;
const configuredBinding =
!meta || !normalizeText(meta.agent)
? resolveConfiguredAcpBindingSpecBySessionKey({
cfg: params.cfg,
sessionKey,
})
: null;
if (!meta) {
if (configuredBinding) {
const ensured = await ensureConfiguredAcpBindingSession({
@@ -189,7 +213,7 @@ export async function resetAcpSessionInPlace(params: {
return { ok: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logVerbose(`acp-persistent-binding: failed reset for ${sessionKey}: ${message}`);
logVerbose(`acp-configured-binding: failed reset for ${sessionKey}: ${message}`);
return {
ok: false,
error: message,

View File

@@ -1,275 +1,17 @@
import { getChannelPlugin } from "../channels/plugins/index.js";
import { listAcpBindings } from "../config/bindings.js";
import {
resolveConfiguredBindingRecord,
resolveConfiguredBindingRecordBySessionKey,
resolveConfiguredBindingRecordForConversation,
} from "../channels/plugins/binding-registry.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentAcpBinding } from "../config/types.js";
import { pickFirstExistingAgentId } from "../routing/resolve-route.js";
import type { ConversationRef } from "../infra/outbound/session-binding-service.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import {
buildConfiguredAcpSessionKey,
normalizeBindingConfig,
normalizeMode,
normalizeText,
toConfiguredAcpBindingRecord,
type ConfiguredAcpBindingChannel,
resolveConfiguredAcpBindingSpecFromRecord,
toResolvedConfiguredAcpBinding,
type ConfiguredAcpBindingSpec,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.types.js";
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
const normalized = (value ?? "").trim().toLowerCase();
if (!normalized) {
return null;
}
const plugin = getChannelPlugin(normalized);
return plugin?.acpBindings ? plugin.id : null;
}
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
const trimmed = (match ?? "").trim();
if (!trimmed) {
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
}
if (trimmed === "*") {
return 1;
}
return normalizeAccountId(trimmed) === actual ? 2 : 0;
}
function resolveBindingConversationId(binding: AgentAcpBinding): string | null {
const id = binding.match.peer?.id?.trim();
return id ? id : null;
}
function parseConfiguredBindingSessionKey(params: {
sessionKey: string;
}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
const parsed = parseAgentSessionKey(params.sessionKey);
const rest = parsed?.rest?.trim().toLowerCase() ?? "";
if (!rest) {
return null;
}
const tokens = rest.split(":");
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
return null;
}
const channel = normalizeBindingChannel(tokens[2]);
if (!channel) {
return null;
}
return {
channel,
accountId: normalizeAccountId(tokens[3]),
};
}
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
acpAgentId?: string;
mode?: string;
cwd?: string;
backend?: string;
} {
const agent = params.cfg.agents?.list?.find(
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
);
if (!agent || agent.runtime?.type !== "acp") {
return {};
}
return {
acpAgentId: normalizeText(agent.runtime.acp?.agent),
mode: normalizeText(agent.runtime.acp?.mode),
cwd: normalizeText(agent.runtime.acp?.cwd),
backend: normalizeText(agent.runtime.acp?.backend),
};
}
function toConfiguredBindingSpec(params: {
cfg: OpenClawConfig;
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
binding: AgentAcpBinding;
}): ConfiguredAcpBindingSpec {
const accountId = normalizeAccountId(params.accountId);
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
cfg: params.cfg,
ownerAgentId: agentId,
});
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
return {
channel: params.channel,
accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
agentId,
acpAgentId,
mode,
cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd,
backend: bindingOverrides.backend ?? runtimeDefaults.backend,
label: bindingOverrides.label,
};
}
function resolveConfiguredBindingRecord(params: {
cfg: OpenClawConfig;
bindings: AgentAcpBinding[];
channel: ConfiguredAcpBindingChannel;
accountId: string;
selectConversation: (binding: AgentAcpBinding) => {
conversationId: string;
parentConversationId?: string;
matchPriority?: number;
} | null;
}): ResolvedConfiguredAcpBinding | null {
let wildcardMatch: {
binding: AgentAcpBinding;
conversationId: string;
parentConversationId?: string;
matchPriority: number;
} | null = null;
let exactMatch: {
binding: AgentAcpBinding;
conversationId: string;
parentConversationId?: string;
matchPriority: number;
} | null = null;
for (const binding of params.bindings) {
if (normalizeBindingChannel(binding.match.channel) !== params.channel) {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(
binding.match.accountId,
params.accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const conversation = params.selectConversation(binding);
if (!conversation) {
continue;
}
const matchPriority = conversation.matchPriority ?? 0;
if (accountMatchPriority === 2) {
if (!exactMatch || matchPriority > exactMatch.matchPriority) {
exactMatch = {
binding,
conversationId: conversation.conversationId,
parentConversationId: conversation.parentConversationId,
matchPriority,
};
}
continue;
}
if (!wildcardMatch || matchPriority > wildcardMatch.matchPriority) {
wildcardMatch = {
binding,
conversationId: conversation.conversationId,
parentConversationId: conversation.parentConversationId,
matchPriority,
};
}
}
if (exactMatch) {
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
conversationId: exactMatch.conversationId,
parentConversationId: exactMatch.parentConversationId,
binding: exactMatch.binding,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
if (!wildcardMatch) {
return null;
}
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
conversationId: wildcardMatch.conversationId,
parentConversationId: wildcardMatch.parentConversationId,
binding: wildcardMatch.binding,
});
return {
spec,
record: toConfiguredAcpBindingRecord(spec),
};
}
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): ConfiguredAcpBindingSpec | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey });
if (!parsedSessionKey) {
return null;
}
const plugin = getChannelPlugin(parsedSessionKey.channel);
const acpBindings = plugin?.acpBindings;
if (!acpBindings?.normalizeConfiguredBindingTarget) {
return null;
}
let wildcardMatch: ConfiguredAcpBindingSpec | null = null;
for (const binding of listAcpBindings(params.cfg)) {
const channel = normalizeBindingChannel(binding.match.channel);
if (!channel || channel !== parsedSessionKey.channel) {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(
binding.match.accountId,
parsedSessionKey.accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const targetConversationId = resolveBindingConversationId(binding);
if (!targetConversationId) {
continue;
}
const target = acpBindings.normalizeConfiguredBindingTarget({
binding,
conversationId: targetConversationId,
});
if (!target) {
continue;
}
const spec = toConfiguredBindingSpec({
cfg: params.cfg,
channel,
accountId: parsedSessionKey.accountId,
conversationId: target.conversationId,
parentConversationId: target.parentConversationId,
binding,
});
if (buildConfiguredAcpSessionKey(spec) !== sessionKey) {
continue;
}
if (accountMatchPriority === 2) {
return spec;
}
if (!wildcardMatch) {
wildcardMatch = spec;
}
}
return wildcardMatch;
}
export function resolveConfiguredAcpBindingRecord(params: {
cfg: OpenClawConfig;
channel: string;
@@ -277,36 +19,22 @@ export function resolveConfiguredAcpBindingRecord(params: {
conversationId: string;
parentConversationId?: string;
}): ResolvedConfiguredAcpBinding | null {
const channel = normalizeBindingChannel(params.channel);
const accountId = normalizeAccountId(params.accountId);
const conversationId = params.conversationId.trim();
const parentConversationId = params.parentConversationId?.trim() || undefined;
if (!channel || !conversationId) {
return null;
}
const plugin = getChannelPlugin(channel);
const acpBindings = plugin?.acpBindings;
if (!acpBindings?.matchConfiguredBinding) {
return null;
}
const matchConfiguredBinding = acpBindings.matchConfiguredBinding;
return resolveConfiguredBindingRecord({
cfg: params.cfg,
bindings: listAcpBindings(params.cfg),
channel,
accountId,
selectConversation: (binding) => {
const bindingConversationId = resolveBindingConversationId(binding);
if (!bindingConversationId) {
return null;
}
return matchConfiguredBinding({
binding,
bindingConversationId,
conversationId,
parentConversationId,
});
},
});
const resolved = resolveConfiguredBindingRecord(params);
return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null;
}
export function resolveConfiguredAcpBindingRecordForConversation(params: {
cfg: OpenClawConfig;
conversation: ConversationRef;
}): ResolvedConfiguredAcpBinding | null {
const resolved = resolveConfiguredBindingRecordForConversation(params);
return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null;
}
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): ConfiguredAcpBindingSpec | null {
const resolved = resolveConfiguredBindingRecordBySessionKey(params);
return resolved ? resolveConfiguredAcpBindingSpecFromRecord(resolved.record) : null;
}

View File

@@ -1,81 +0,0 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
import { deriveLastRoutePolicy } from "../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import {
ensureConfiguredAcpBindingSession,
resolveConfiguredAcpBindingRecord,
type ConfiguredAcpBindingChannel,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.js";
export function resolveConfiguredAcpRoute(params: {
cfg: OpenClawConfig;
route: ResolvedAgentRoute;
channel: ConfiguredAcpBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
}): {
configuredBinding: ResolvedConfiguredAcpBinding | null;
route: ResolvedAgentRoute;
boundSessionKey?: string;
boundAgentId?: string;
} {
const configuredBinding = resolveConfiguredAcpBindingRecord({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
if (!configuredBinding) {
return {
configuredBinding: null,
route: params.route,
};
}
const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? "";
if (!boundSessionKey) {
return {
configuredBinding,
route: params.route,
};
}
const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId;
return {
configuredBinding,
boundSessionKey,
boundAgentId,
route: {
...params.route,
sessionKey: boundSessionKey,
agentId: boundAgentId,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: params.route.mainSessionKey,
}),
matchedBy: "binding.channel",
},
};
}
export async function ensureConfiguredAcpRouteReady(params: {
cfg: OpenClawConfig;
configuredBinding: ResolvedConfiguredAcpBinding | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (!params.configuredBinding) {
return { ok: true };
}
const ensured = await ensureConfiguredAcpBindingSession({
cfg: params.cfg,
spec: params.configuredBinding.spec,
});
if (ensured.ok) {
return { ok: true };
}
return {
ok: false,
error: ensured.error ?? "unknown error",
};
}

View File

@@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { discordPlugin } from "../../extensions/discord/src/channel.js";
import { feishuPlugin } from "../../extensions/feishu/src/channel.js";
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
import { importFreshModule } from "../../test/helpers/import-fresh.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js";
const managerMocks = vi.hoisted(() => ({
resolveSession: vi.fn(),
closeSession: vi.fn(),
@@ -27,17 +30,24 @@ vi.mock("./runtime/session-meta.js", () => ({
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
}));
type PersistentBindingsModule = typeof import("./persistent-bindings.js");
let buildConfiguredAcpSessionKey: PersistentBindingsModule["buildConfiguredAcpSessionKey"];
let ensureConfiguredAcpBindingSession: PersistentBindingsModule["ensureConfiguredAcpBindingSession"];
let resetAcpSessionInPlace: PersistentBindingsModule["resetAcpSessionInPlace"];
let resolveConfiguredAcpBindingRecord: PersistentBindingsModule["resolveConfiguredAcpBindingRecord"];
let resolveConfiguredAcpBindingSpecBySessionKey: PersistentBindingsModule["resolveConfiguredAcpBindingSpecBySessionKey"];
type PersistentBindingsModule = Pick<
typeof import("./persistent-bindings.resolve.js"),
"resolveConfiguredAcpBindingRecord" | "resolveConfiguredAcpBindingSpecBySessionKey"
> &
Pick<
typeof import("./persistent-bindings.lifecycle.js"),
"ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace"
>;
let persistentBindings: PersistentBindingsModule;
let persistentBindingsImportScope = 0;
type ConfiguredBinding = NonNullable<OpenClawConfig["bindings"]>[number];
type BindingRecordInput = Parameters<typeof resolveConfiguredAcpBindingRecord>[0];
type BindingSpec = Parameters<typeof ensureConfiguredAcpBindingSession>[0]["spec"];
type BindingRecordInput = Parameters<
PersistentBindingsModule["resolveConfiguredAcpBindingRecord"]
>[0];
type BindingSpec = Parameters<
PersistentBindingsModule["ensureConfiguredAcpBindingSession"]
>[0]["spec"];
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
@@ -117,7 +127,7 @@ function createFeishuBinding(params: {
}
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
return resolveConfiguredAcpBindingRecord({
return persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "discord",
accountId: defaultDiscordAccountId,
@@ -131,7 +141,7 @@ function resolveDiscordBindingSpecBySession(
conversationId = defaultDiscordConversationId,
) {
const resolved = resolveBindingRecord(cfg, { conversationId });
return resolveConfiguredAcpBindingSpecBySessionKey({
return persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
cfg,
sessionKey: resolved?.record.targetSessionKey ?? "",
});
@@ -148,7 +158,11 @@ function createDiscordPersistentSpec(overrides: Partial<BindingSpec> = {}): Bind
} as BindingSpec;
}
function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
function mockReadySession(params: {
spec: BindingSpec;
cwd: string;
state?: "idle" | "running" | "error";
}) {
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
managerMocks.resolveSession.mockReturnValue({
kind: "ready",
@@ -159,14 +173,33 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
runtimeSessionName: "existing",
mode: params.spec.mode,
runtimeOptions: { cwd: params.cwd },
state: "idle",
state: params.state ?? "idle",
lastActivityAt: Date.now(),
},
});
return sessionKey;
}
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
persistentBindingsImportScope += 1;
const [resolveModule, lifecycleModule] = await Promise.all([
importFreshModule<typeof import("./persistent-bindings.resolve.js")>(
import.meta.url,
`./persistent-bindings.resolve.js?scope=${persistentBindingsImportScope}`,
),
importFreshModule<typeof import("./persistent-bindings.lifecycle.js")>(
import.meta.url,
`./persistent-bindings.lifecycle.js?scope=${persistentBindingsImportScope}`,
),
]);
persistentBindings = {
resolveConfiguredAcpBindingRecord: resolveModule.resolveConfiguredAcpBindingRecord,
resolveConfiguredAcpBindingSpecBySessionKey:
resolveModule.resolveConfiguredAcpBindingSpecBySessionKey,
ensureConfiguredAcpBindingSession: lifecycleModule.ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace: lifecycleModule.resetAcpSessionInPlace,
};
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
@@ -184,17 +217,6 @@ beforeEach(() => {
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
});
beforeEach(async () => {
vi.resetModules();
({
buildConfiguredAcpSessionKey,
ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace,
resolveConfiguredAcpBindingRecord,
resolveConfiguredAcpBindingSpecBySessionKey,
} = await import("./persistent-bindings.js"));
});
describe("resolveConfiguredAcpBindingRecord", () => {
it("resolves discord channel ACP binding from top-level typed bindings", () => {
const cfg = createCfgWithBindings([
@@ -263,7 +285,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "work",
@@ -318,13 +340,13 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const canonical = resolveConfiguredAcpBindingRecord({
const canonical = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
conversationId: "-1001234567890:topic:42",
});
const splitIds = resolveConfiguredAcpBindingRecord({
const splitIds = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
@@ -347,7 +369,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "telegram",
accountId: "default",
@@ -364,7 +386,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
@@ -384,7 +406,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
@@ -405,7 +427,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
@@ -427,7 +449,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
@@ -449,7 +471,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
@@ -468,7 +490,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
@@ -514,6 +536,25 @@ describe("resolveConfiguredAcpBindingRecord", () => {
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
expect(resolved?.spec.backend).toBe("acpx");
});
it("derives configured binding cwd from an explicit agent workspace", () => {
const cfg = createCfgWithBindings(
[
createDiscordBinding({
agentId: "codex",
conversationId: defaultDiscordConversationId,
}),
],
{
agents: {
list: [{ id: "codex", workspace: "/workspace/openclaw" }, { id: "claude" }],
},
},
);
const resolved = resolveBindingRecord(cfg);
expect(resolved?.spec.cwd).toBe(resolveAgentWorkspaceDir(cfg, "codex"));
});
});
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
@@ -534,7 +575,7 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
});
it("returns null for unknown session keys", () => {
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
cfg: baseCfg,
sessionKey: "agent:main:acp:binding:discord:default:notfound",
});
@@ -568,13 +609,13 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
acp: { backend: "acpx" },
}),
]);
const resolved = resolveConfiguredAcpBindingRecord({
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
cfg,
channel: "feishu",
accountId: "default",
conversationId: "user_123",
});
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
cfg,
sessionKey: resolved?.record.targetSessionKey ?? "",
});
@@ -614,7 +655,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
cwd: "/workspace/openclaw",
});
const ensured = await ensureConfiguredAcpBindingSession({
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
@@ -633,7 +674,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
cwd: "/workspace/other-repo",
});
const ensured = await ensureConfiguredAcpBindingSession({
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
@@ -649,6 +690,26 @@ describe("ensureConfiguredAcpBindingSession", () => {
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
});
it("keeps a matching ready session even when the stored ACP session is in error state", async () => {
const spec = createDiscordPersistentSpec({
cwd: "/home/bob/clawd",
});
const sessionKey = mockReadySession({
spec,
cwd: "/home/bob/clawd",
state: "error",
});
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
expect(ensured).toEqual({ ok: true, sessionKey });
expect(managerMocks.closeSession).not.toHaveBeenCalled();
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
});
it("initializes ACP session with runtime agent override when provided", async () => {
const spec = createDiscordPersistentSpec({
agentId: "coding",
@@ -656,7 +717,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
});
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
const ensured = await ensureConfiguredAcpBindingSession({
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
cfg: baseCfg,
spec,
});
@@ -692,7 +753,7 @@ describe("resetAcpSessionInPlace", () => {
});
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
const result = await resetAcpSessionInPlace({
const result = await persistentBindings.resetAcpSessionInPlace({
cfg,
sessionKey,
reason: "new",
@@ -721,7 +782,7 @@ describe("resetAcpSessionInPlace", () => {
});
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
const result = await resetAcpSessionInPlace({
const result = await persistentBindings.resetAcpSessionInPlace({
cfg: baseCfg,
sessionKey,
reason: "reset",
@@ -752,7 +813,7 @@ describe("resetAcpSessionInPlace", () => {
},
});
const result = await resetAcpSessionInPlace({
const result = await persistentBindings.resetAcpSessionInPlace({
cfg,
sessionKey,
reason: "reset",
@@ -766,4 +827,64 @@ describe("resetAcpSessionInPlace", () => {
}),
);
});
it("preserves configured ACP agent overrides during in-place reset when metadata omits the agent", async () => {
const cfg = createCfgWithBindings(
[
createDiscordBinding({
agentId: "coding",
conversationId: "1478844424791396446",
}),
],
{
agents: {
list: [
{ id: "main" },
{
id: "coding",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "persistent",
},
},
},
{ id: "claude" },
],
},
},
);
const sessionKey = buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "default",
conversationId: "1478844424791396446",
agentId: "coding",
acpAgentId: "codex",
mode: "persistent",
backend: "acpx",
});
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
acp: {
mode: "persistent",
backend: "acpx",
},
});
const result = await persistentBindings.resetAcpSessionInPlace({
cfg,
sessionKey,
reason: "reset",
});
expect(result).toEqual({ ok: true });
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey,
agent: "codex",
backendId: "acpx",
}),
);
});
});

View File

@@ -1,19 +0,0 @@
export {
buildConfiguredAcpSessionKey,
normalizeBindingConfig,
normalizeMode,
normalizeText,
toConfiguredAcpBindingRecord,
type AcpBindingConfigShape,
type ConfiguredAcpBindingChannel,
type ConfiguredAcpBindingSpec,
type ResolvedConfiguredAcpBinding,
} from "./persistent-bindings.types.js";
export {
ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace,
} from "./persistent-bindings.lifecycle.js";
export {
resolveConfiguredAcpBindingRecord,
resolveConfiguredAcpBindingSpecBySessionKey,
} from "./persistent-bindings.resolve.js";

View File

@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import type { ChannelId } from "../channels/plugins/types.js";
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { sanitizeAgentId } from "../routing/session-key.js";
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
@@ -104,3 +105,72 @@ export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): Se
},
};
}
export function parseConfiguredAcpSessionKey(
sessionKey: string,
): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
const trimmed = sessionKey.trim();
if (!trimmed.startsWith("agent:")) {
return null;
}
const rest = trimmed.slice(trimmed.indexOf(":") + 1);
const nextSeparator = rest.indexOf(":");
if (nextSeparator === -1) {
return null;
}
const tokens = rest.slice(nextSeparator + 1).split(":");
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
return null;
}
const channel = tokens[2]?.trim().toLowerCase();
if (!channel) {
return null;
}
return {
channel: channel as ConfiguredAcpBindingChannel,
accountId: normalizeAccountId(tokens[3] ?? "default"),
};
}
export function resolveConfiguredAcpBindingSpecFromRecord(
record: SessionBindingRecord,
): ConfiguredAcpBindingSpec | null {
if (record.targetKind !== "session") {
return null;
}
const conversationId = record.conversation.conversationId.trim();
if (!conversationId) {
return null;
}
const agentId =
normalizeText(record.metadata?.agentId) ??
resolveAgentIdFromSessionKey(record.targetSessionKey);
if (!agentId) {
return null;
}
return {
channel: record.conversation.channel as ConfiguredAcpBindingChannel,
accountId: normalizeAccountId(record.conversation.accountId),
conversationId,
parentConversationId: normalizeText(record.conversation.parentConversationId),
agentId,
acpAgentId: normalizeText(record.metadata?.acpAgentId),
mode: normalizeMode(record.metadata?.mode),
cwd: normalizeText(record.metadata?.cwd),
backend: normalizeText(record.metadata?.backend),
label: normalizeText(record.metadata?.label),
};
}
export function toResolvedConfiguredAcpBinding(
record: SessionBindingRecord,
): ResolvedConfiguredAcpBinding | null {
const spec = resolveConfiguredAcpBindingSpecFromRecord(record);
if (!spec) {
return null;
}
return {
spec,
record,
};
}

View File

@@ -165,6 +165,7 @@ export async function upsertAcpSessionMeta(params: {
},
{
activeSessionKey: sessionKey.toLowerCase(),
allowDropAcpMetaSessionKeys: [sessionKey],
},
);
}

View File

@@ -1,4 +1,4 @@
import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js";
import { resolveConfiguredBindingRecord } from "../../channels/plugins/binding-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
@@ -51,7 +51,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
return undefined;
}
const configuredBinding = resolveConfiguredAcpBindingRecord({
const configuredBinding = resolveConfiguredBindingRecord({
cfg: params.cfg,
channel,
accountId,

View File

@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js";
import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
@@ -228,7 +228,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
? boundAcpSessionKey.trim()
: undefined;
if (boundAcpKey) {
const resetResult = await resetAcpSessionInPlace({
const resetResult = await resetConfiguredBindingTargetInPlace({
cfg: params.cfg,
sessionKey: boundAcpKey,
reason: commandAction,

View File

@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { discordPlugin } from "../../../extensions/discord/src/channel.js";
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { MsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -192,14 +193,16 @@ vi.mock("../../tts/tts.js", () => ({
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
}));
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js");
const { __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js");
const noAbortResult = { handled: false, aborted: false } as const;
const emptyConfig = {} as OpenClawConfig;
type DispatchReplyArgs = Parameters<typeof dispatchReplyFromConfig>[0];
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
let acpManagerTesting: typeof import("../../acp/control-plane/manager.js").__testing;
let pluginBindingTesting: typeof import("../../plugins/conversation-binding.js").__testing;
let AcpRuntimeErrorClass: typeof import("../../acp/runtime/errors.js").AcpRuntimeError;
type DispatchReplyArgs = Parameters<
typeof import("./dispatch-from-config.js").dispatchReplyFromConfig
>[0];
function createDispatcher(): ReplyDispatcher {
return {
@@ -254,9 +257,39 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
}
describe("dispatchReplyFromConfig", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
({ resetInboundDedupe } = await import("./inbound-dedupe.js"));
({ __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"));
({ __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js"));
({ AcpRuntimeError: AcpRuntimeErrorClass } = await import("../../acp/runtime/errors.js"));
const discordTestPlugin = {
...createChannelTestPluginBase({
id: "discord",
capabilities: {
chatTypes: ["direct"],
nativeCommands: true,
},
}),
execApprovals: {
shouldSuppressLocalPrompt: ({ payload }: { payload: ReplyPayload }) =>
Boolean(
payload.channelData &&
typeof payload.channelData === "object" &&
!Array.isArray(payload.channelData) &&
payload.channelData.execApproval,
),
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
createTestRegistry([
{
pluginId: "discord",
source: "test",
plugin: discordTestPlugin,
},
]),
);
acpManagerTesting.resetAcpSessionManagerForTests();
resetInboundDedupe();
@@ -1733,7 +1766,7 @@ describe("dispatchReplyFromConfig", () => {
},
});
acpMocks.requireAcpRuntimeBackend.mockImplementation(() => {
throw new AcpRuntimeError(
throw new AcpRuntimeErrorClass(
"ACP_BACKEND_MISSING",
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
);

View File

@@ -1,4 +1,8 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import {
resolveConversationBindingRecord,
touchConversationBindingRecord,
} from "../../bindings/records.js";
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
@@ -20,7 +24,6 @@ import {
toPluginMessageReceivedEvent,
} from "../../hooks/message-hook-mappers.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import {
logMessageProcessed,
logMessageQueued,
@@ -303,7 +306,7 @@ export async function dispatchReplyFromConfig(params: {
const pluginOwnedBindingRecord =
inboundClaimContext.conversationId && inboundClaimContext.channelId
? getSessionBindingService().resolveByConversation({
? resolveConversationBindingRecord({
channel: inboundClaimContext.channelId,
accountId: inboundClaimContext.accountId ?? "default",
conversationId: inboundClaimContext.conversationId,
@@ -320,7 +323,7 @@ export async function dispatchReplyFromConfig(params: {
| undefined;
if (pluginOwnedBinding) {
getSessionBindingService().touch(pluginOwnedBinding.bindingId);
touchConversationBindingRecord(pluginOwnedBinding.bindingId);
logVerbose(
`plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`,
);

View File

@@ -99,6 +99,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
httpRoutes: [],
cliRegistrars: [],
services: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
});
@@ -300,7 +301,7 @@ describe("routeReply", () => {
});
it("passes thread id to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi" },
channel: "telegram",
@@ -308,10 +309,12 @@ describe("routeReply", () => {
threadId: 42,
cfg: {} as never,
});
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
"telegram:123",
"hi",
expect.objectContaining({ messageThreadId: 42 }),
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "telegram:123",
threadId: 42,
}),
);
});
@@ -346,17 +349,19 @@ describe("routeReply", () => {
});
it("passes replyToId to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi", replyToId: "123" },
channel: "telegram",
to: "telegram:123",
cfg: {} as never,
});
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
"telegram:123",
"hi",
expect.objectContaining({ replyToMessageId: 123 }),
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "telegram:123",
replyToId: "123",
}),
);
});

48
src/bindings/records.ts Normal file
View File

@@ -0,0 +1,48 @@
import {
getSessionBindingService,
type ConversationRef,
type SessionBindingBindInput,
type SessionBindingCapabilities,
type SessionBindingRecord,
type SessionBindingUnbindInput,
} from "../infra/outbound/session-binding-service.js";
// Shared binding record helpers used by both configured bindings and
// runtime-created plugin conversation bindings.
export async function createConversationBindingRecord(
input: SessionBindingBindInput,
): Promise<SessionBindingRecord> {
return await getSessionBindingService().bind(input);
}
export function getConversationBindingCapabilities(params: {
channel: string;
accountId: string;
}): SessionBindingCapabilities {
return getSessionBindingService().getCapabilities(params);
}
export function listSessionBindingRecords(targetSessionKey: string): SessionBindingRecord[] {
return getSessionBindingService().listBySession(targetSessionKey);
}
export function resolveConversationBindingRecord(
conversation: ConversationRef,
): SessionBindingRecord | null {
return getSessionBindingService().resolveByConversation(conversation);
}
export function touchConversationBindingRecord(bindingId: string, at?: number): void {
const service = getSessionBindingService();
if (typeof at === "number") {
service.touch(bindingId, at);
return;
}
service.touch(bindingId);
}
export async function unbindConversationBindingRecord(
input: SessionBindingUnbindInput,
): Promise<SessionBindingRecord[]> {
return await getSessionBindingService().unbind(input);
}

View File

@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.types.js";
const resolveAgentConfigMock = vi.hoisted(() => vi.fn());
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn());
const getChannelPluginMock = vi.hoisted(() => vi.fn());
const getActivePluginRegistryMock = vi.hoisted(() => vi.fn());
const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn());
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: (...args: unknown[]) => resolveAgentConfigMock(...args),
resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args),
resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args),
}));
vi.mock("./index.js", () => ({
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
}));
vi.mock("../../plugins/runtime.js", () => ({
getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args),
getActivePluginRegistryVersion: (...args: unknown[]) =>
getActivePluginRegistryVersionMock(...args),
}));
async function importConfiguredBindings() {
const builtins = await import("./configured-binding-builtins.js");
builtins.ensureConfiguredBindingBuiltinsRegistered();
return await import("./configured-binding-registry.js");
}
function createConfig(options?: { bindingAgentId?: string; accountId?: string }) {
return {
agents: {
list: [{ id: "main" }, { id: "codex" }],
},
bindings: [
{
type: "acp",
agentId: options?.bindingAgentId ?? "codex",
match: {
channel: "discord",
accountId: options?.accountId ?? "default",
peer: {
kind: "channel",
id: "1479098716916023408",
},
},
acp: {
backend: "acpx",
},
},
],
};
}
function createDiscordAcpPlugin(overrides?: {
compileConfiguredBinding?: ReturnType<typeof vi.fn>;
matchInboundConversation?: ReturnType<typeof vi.fn>;
}) {
const compileConfiguredBinding =
overrides?.compileConfiguredBinding ??
vi.fn(({ conversationId }: { conversationId: string }) => ({
conversationId,
}));
const matchInboundConversation =
overrides?.matchInboundConversation ??
vi.fn(
({
compiledBinding,
conversationId,
parentConversationId,
}: {
compiledBinding: { conversationId: string };
conversationId: string;
parentConversationId?: string;
}) => {
if (compiledBinding.conversationId === conversationId) {
return { conversationId, matchPriority: 2 };
}
if (parentConversationId && compiledBinding.conversationId === parentConversationId) {
return { conversationId: parentConversationId, matchPriority: 1 };
}
return null;
},
);
return {
id: "discord",
bindings: {
compileConfiguredBinding,
matchInboundConversation,
},
};
}
describe("configured binding registry", () => {
beforeEach(() => {
vi.resetModules();
resolveAgentConfigMock.mockReset().mockReturnValue(undefined);
resolveDefaultAgentIdMock.mockReset().mockReturnValue("main");
resolveAgentWorkspaceDirMock.mockReset().mockReturnValue("/tmp/workspace");
getChannelPluginMock.mockReset();
getActivePluginRegistryMock.mockReset().mockReturnValue({ channels: [] });
getActivePluginRegistryVersionMock.mockReset().mockReturnValue(1);
});
it("resolves configured ACP bindings from an already loaded channel plugin", async () => {
const plugin = createDiscordAcpPlugin();
getChannelPluginMock.mockReturnValue(plugin);
const bindingRegistry = await importConfiguredBindings();
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
cfg: createConfig() as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(resolved?.record.conversation.channel).toBe("discord");
expect(resolved?.record.metadata?.backend).toBe("acpx");
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1);
});
it("resolves configured ACP bindings from canonical conversation refs", async () => {
const plugin = createDiscordAcpPlugin();
getChannelPluginMock.mockReturnValue(plugin);
const bindingRegistry = await importConfiguredBindings();
const resolved = bindingRegistry.resolveConfiguredBinding({
cfg: createConfig() as never,
conversation: {
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
},
});
expect(resolved?.conversation).toEqual({
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(resolved?.record.conversation.channel).toBe("discord");
expect(resolved?.statefulTarget).toEqual({
kind: "stateful",
driverId: "acp",
sessionKey: resolved?.record.targetSessionKey,
agentId: "codex",
label: undefined,
});
});
it("primes compiled ACP bindings from the already loaded active registry once", async () => {
const plugin = createDiscordAcpPlugin();
const cfg = createConfig({ bindingAgentId: "codex" });
getChannelPluginMock.mockReturnValue(undefined);
getActivePluginRegistryMock.mockReturnValue({
channels: [{ plugin }],
});
const bindingRegistry = await importConfiguredBindings();
const primed = bindingRegistry.primeConfiguredBindingRegistry({
cfg: cfg as never,
});
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(primed).toEqual({ bindingCount: 1, channelCount: 1 });
expect(resolved?.statefulTarget.agentId).toBe("codex");
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1);
const second = bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(second?.statefulTarget.agentId).toBe("codex");
});
it("resolves wildcard binding session keys from the compiled registry", async () => {
const plugin = createDiscordAcpPlugin();
getChannelPluginMock.mockReturnValue(plugin);
const bindingRegistry = await importConfiguredBindings();
const resolved = bindingRegistry.resolveConfiguredBindingRecordBySessionKey({
cfg: createConfig({ accountId: "*" }) as never,
sessionKey: buildConfiguredAcpSessionKey({
channel: "discord",
accountId: "work",
conversationId: "1479098716916023408",
agentId: "codex",
mode: "persistent",
backend: "acpx",
}),
});
expect(resolved?.record.conversation.channel).toBe("discord");
expect(resolved?.record.conversation.accountId).toBe("work");
expect(resolved?.record.metadata?.backend).toBe("acpx");
});
it("does not perform late plugin discovery when a channel plugin is unavailable", async () => {
const bindingRegistry = await importConfiguredBindings();
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
cfg: createConfig() as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(resolved).toBeNull();
});
it("rebuilds the compiled registry when the active plugin registry version changes", async () => {
const plugin = createDiscordAcpPlugin();
getChannelPluginMock.mockReturnValue(plugin);
getActivePluginRegistryVersionMock.mockReturnValue(10);
const cfg = createConfig();
const bindingRegistry = await importConfiguredBindings();
bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
getActivePluginRegistryVersionMock.mockReturnValue(11);
bindingRegistry.resolveConfiguredBindingRecord({
cfg: cfg as never,
channel: "discord",
accountId: "default",
conversationId: "1479098716916023408",
});
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,155 @@
import {
buildConfiguredAcpSessionKey,
normalizeBindingConfig,
normalizeMode,
normalizeText,
parseConfiguredAcpSessionKey,
toConfiguredAcpBindingRecord,
type ConfiguredAcpBindingSpec,
} from "../../acp/persistent-bindings.types.js";
import {
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/config.js";
import type {
ConfiguredBindingRuleConfig,
ConfiguredBindingTargetFactory,
} from "./binding-types.js";
import type { ConfiguredBindingConsumer } from "./configured-binding-consumers.js";
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
acpAgentId?: string;
mode?: string;
cwd?: string;
backend?: string;
} {
const agent = params.cfg.agents?.list?.find(
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
);
if (!agent || agent.runtime?.type !== "acp") {
return {};
}
return {
acpAgentId: normalizeText(agent.runtime.acp?.agent),
mode: normalizeText(agent.runtime.acp?.mode),
cwd: normalizeText(agent.runtime.acp?.cwd),
backend: normalizeText(agent.runtime.acp?.backend),
};
}
function resolveConfiguredBindingWorkspaceCwd(params: {
cfg: OpenClawConfig;
agentId: string;
}): string | undefined {
const explicitAgentWorkspace = normalizeText(
resolveAgentConfig(params.cfg, params.agentId)?.workspace,
);
if (explicitAgentWorkspace) {
return resolveAgentWorkspaceDir(params.cfg, params.agentId);
}
if (params.agentId === resolveDefaultAgentId(params.cfg)) {
const defaultWorkspace = normalizeText(params.cfg.agents?.defaults?.workspace);
if (defaultWorkspace) {
return resolveAgentWorkspaceDir(params.cfg, params.agentId);
}
}
return undefined;
}
function buildConfiguredAcpSpec(params: {
channel: string;
accountId: string;
conversation: ChannelConfiguredBindingConversationRef;
agentId: string;
acpAgentId?: string;
mode: "persistent" | "oneshot";
cwd?: string;
backend?: string;
label?: string;
}): ConfiguredAcpBindingSpec {
return {
channel: params.channel as ConfiguredAcpBindingSpec["channel"],
accountId: params.accountId,
conversationId: params.conversation.conversationId,
parentConversationId: params.conversation.parentConversationId,
agentId: params.agentId,
acpAgentId: params.acpAgentId,
mode: params.mode,
cwd: params.cwd,
backend: params.backend,
label: params.label,
};
}
function buildAcpTargetFactory(params: {
cfg: OpenClawConfig;
binding: ConfiguredBindingRuleConfig;
channel: string;
agentId: string;
}): ConfiguredBindingTargetFactory | null {
if (params.binding.type !== "acp") {
return null;
}
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
cfg: params.cfg,
ownerAgentId: params.agentId,
});
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
const cwd =
bindingOverrides.cwd ??
runtimeDefaults.cwd ??
resolveConfiguredBindingWorkspaceCwd({
cfg: params.cfg,
agentId: params.agentId,
});
const backend = bindingOverrides.backend ?? runtimeDefaults.backend;
const label = bindingOverrides.label;
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
return {
driverId: "acp",
materialize: ({ accountId, conversation }) => {
const spec = buildConfiguredAcpSpec({
channel: params.channel,
accountId,
conversation,
agentId: params.agentId,
acpAgentId,
mode,
cwd,
backend,
label,
});
const record = toConfiguredAcpBindingRecord(spec);
return {
record,
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: buildConfiguredAcpSessionKey(spec),
agentId: params.agentId,
...(label ? { label } : {}),
},
};
},
};
}
export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = {
id: "acp",
supports: (binding) => binding.type === "acp",
buildTargetFactory: (params) =>
buildAcpTargetFactory({
cfg: params.cfg,
binding: params.binding,
channel: params.channel,
agentId: params.agentId,
}),
parseSessionKey: ({ sessionKey }) => parseConfiguredAcpSessionKey(sessionKey),
matchesSessionKey: ({ sessionKey, materializedTarget }) =>
materializedTarget.record.targetSessionKey === sessionKey,
};

View File

@@ -0,0 +1,102 @@
import {
ensureConfiguredAcpBindingReady,
ensureConfiguredAcpBindingSession,
resetAcpSessionInPlace,
} from "../../acp/persistent-bindings.lifecycle.js";
import { resolveConfiguredAcpBindingSpecBySessionKey } from "../../acp/persistent-bindings.resolve.js";
import { resolveConfiguredAcpBindingSpecFromRecord } from "../../acp/persistent-bindings.types.js";
import { readAcpSessionEntry } from "../../acp/runtime/session-meta.js";
import type { OpenClawConfig } from "../../config/config.js";
import type {
ConfiguredBindingResolution,
StatefulBindingTargetDescriptor,
} from "./binding-types.js";
import type {
StatefulBindingTargetDriver,
StatefulBindingTargetResetResult,
StatefulBindingTargetReadyResult,
StatefulBindingTargetSessionResult,
} from "./stateful-target-drivers.js";
function toAcpStatefulBindingTargetDescriptor(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): StatefulBindingTargetDescriptor | null {
const meta = readAcpSessionEntry(params)?.acp;
const metaAgentId = meta?.agent?.trim();
if (metaAgentId) {
return {
kind: "stateful",
driverId: "acp",
sessionKey: params.sessionKey,
agentId: metaAgentId,
};
}
const spec = resolveConfiguredAcpBindingSpecBySessionKey(params);
if (!spec) {
return null;
}
return {
kind: "stateful",
driverId: "acp",
sessionKey: params.sessionKey,
agentId: spec.agentId,
...(spec.label ? { label: spec.label } : {}),
};
}
async function ensureAcpTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}): Promise<StatefulBindingTargetReadyResult> {
const configuredBinding = resolveConfiguredAcpBindingSpecFromRecord(
params.bindingResolution.record,
);
if (!configuredBinding) {
return {
ok: false,
error: "Configured ACP binding unavailable",
};
}
return await ensureConfiguredAcpBindingReady({
cfg: params.cfg,
configuredBinding: {
spec: configuredBinding,
record: params.bindingResolution.record,
},
});
}
async function ensureAcpTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}): Promise<StatefulBindingTargetSessionResult> {
const spec = resolveConfiguredAcpBindingSpecFromRecord(params.bindingResolution.record);
if (!spec) {
return {
ok: false,
sessionKey: params.bindingResolution.statefulTarget.sessionKey,
error: "Configured ACP binding unavailable",
};
}
return await ensureConfiguredAcpBindingSession({
cfg: params.cfg,
spec,
});
}
async function resetAcpTargetInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
reason: "new" | "reset";
}): Promise<StatefulBindingTargetResetResult> {
return await resetAcpSessionInPlace(params);
}
export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = {
id: "acp",
ensureReady: ensureAcpTargetReady,
ensureSession: ensureAcpTargetSession,
resolveTargetBySessionKey: toAcpStatefulBindingTargetDescriptor,
resetInPlace: resetAcpTargetInPlace,
};

View File

@@ -0,0 +1,14 @@
import type { ChannelConfiguredBindingProvider } from "./types.adapters.js";
import type { ChannelPlugin } from "./types.plugin.js";
export function resolveChannelConfiguredBindingProvider(
plugin:
| Pick<ChannelPlugin, "bindings">
| {
bindings?: ChannelConfiguredBindingProvider;
}
| null
| undefined,
): ChannelConfiguredBindingProvider | undefined {
return plugin?.bindings;
}

View File

@@ -0,0 +1,46 @@
import { ensureConfiguredBindingBuiltinsRegistered } from "./configured-binding-builtins.js";
import {
primeConfiguredBindingRegistry as primeConfiguredBindingRegistryRaw,
resolveConfiguredBinding as resolveConfiguredBindingRaw,
resolveConfiguredBindingRecord as resolveConfiguredBindingRecordRaw,
resolveConfiguredBindingRecordBySessionKey as resolveConfiguredBindingRecordBySessionKeyRaw,
resolveConfiguredBindingRecordForConversation as resolveConfiguredBindingRecordForConversationRaw,
} from "./configured-binding-registry.js";
// Thin public wrapper around the configured-binding registry. Runtime plugin
// conversation bindings use a separate approval-driven path in src/plugins/.
export function primeConfiguredBindingRegistry(
...args: Parameters<typeof primeConfiguredBindingRegistryRaw>
): ReturnType<typeof primeConfiguredBindingRegistryRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return primeConfiguredBindingRegistryRaw(...args);
}
export function resolveConfiguredBindingRecord(
...args: Parameters<typeof resolveConfiguredBindingRecordRaw>
): ReturnType<typeof resolveConfiguredBindingRecordRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return resolveConfiguredBindingRecordRaw(...args);
}
export function resolveConfiguredBindingRecordForConversation(
...args: Parameters<typeof resolveConfiguredBindingRecordForConversationRaw>
): ReturnType<typeof resolveConfiguredBindingRecordForConversationRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return resolveConfiguredBindingRecordForConversationRaw(...args);
}
export function resolveConfiguredBinding(
...args: Parameters<typeof resolveConfiguredBindingRaw>
): ReturnType<typeof resolveConfiguredBindingRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return resolveConfiguredBindingRaw(...args);
}
export function resolveConfiguredBindingRecordBySessionKey(
...args: Parameters<typeof resolveConfiguredBindingRecordBySessionKeyRaw>
): ReturnType<typeof resolveConfiguredBindingRecordBySessionKeyRaw> {
ensureConfiguredBindingBuiltinsRegistered();
return resolveConfiguredBindingRecordBySessionKeyRaw(...args);
}

View File

@@ -0,0 +1,91 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
import { deriveLastRoutePolicy } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { resolveConfiguredBinding } from "./binding-registry.js";
import { ensureConfiguredBindingTargetReady } from "./binding-targets.js";
import type { ConfiguredBindingResolution } from "./binding-types.js";
export type ConfiguredBindingRouteResult = {
bindingResolution: ConfiguredBindingResolution | null;
route: ResolvedAgentRoute;
boundSessionKey?: string;
boundAgentId?: string;
};
type ConfiguredBindingRouteConversationInput =
| {
conversation: ConversationRef;
}
| {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
};
function resolveConfiguredBindingConversationRef(
params: ConfiguredBindingRouteConversationInput,
): ConversationRef {
if ("conversation" in params) {
return params.conversation;
}
return {
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
};
}
export function resolveConfiguredBindingRoute(
params: {
cfg: OpenClawConfig;
route: ResolvedAgentRoute;
} & ConfiguredBindingRouteConversationInput,
): ConfiguredBindingRouteResult {
const bindingResolution =
resolveConfiguredBinding({
cfg: params.cfg,
conversation: resolveConfiguredBindingConversationRef(params),
}) ?? null;
if (!bindingResolution) {
return {
bindingResolution: null,
route: params.route,
};
}
const boundSessionKey = bindingResolution.statefulTarget.sessionKey.trim();
if (!boundSessionKey) {
return {
bindingResolution,
route: params.route,
};
}
const boundAgentId =
resolveAgentIdFromSessionKey(boundSessionKey) || bindingResolution.statefulTarget.agentId;
return {
bindingResolution,
boundSessionKey,
boundAgentId,
route: {
...params.route,
sessionKey: boundSessionKey,
agentId: boundAgentId,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: params.route.mainSessionKey,
}),
matchedBy: "binding.channel",
},
};
}
export async function ensureConfiguredBindingRouteReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
return await ensureConfiguredBindingTargetReady(params);
}

View File

@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ensureConfiguredBindingTargetReady,
ensureConfiguredBindingTargetSession,
resetConfiguredBindingTargetInPlace,
} from "./binding-targets.js";
import type { ConfiguredBindingResolution } from "./binding-types.js";
import {
registerStatefulBindingTargetDriver,
unregisterStatefulBindingTargetDriver,
type StatefulBindingTargetDriver,
} from "./stateful-target-drivers.js";
function createBindingResolution(driverId: string): ConfiguredBindingResolution {
return {
conversation: {
channel: "discord",
accountId: "default",
conversationId: "123",
},
compiledBinding: {
channel: "discord",
binding: {
type: "acp" as const,
agentId: "codex",
match: {
channel: "discord",
peer: {
kind: "channel" as const,
id: "123",
},
},
acp: {
mode: "persistent",
},
},
bindingConversationId: "123",
target: {
conversationId: "123",
},
agentId: "codex",
provider: {
compileConfiguredBinding: () => ({
conversationId: "123",
}),
matchInboundConversation: () => ({
conversationId: "123",
}),
},
targetFactory: {
driverId,
materialize: () => ({
record: {
bindingId: "binding:123",
targetSessionKey: `agent:codex:${driverId}`,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "123",
},
status: "active",
boundAt: 0,
},
statefulTarget: {
kind: "stateful",
driverId,
sessionKey: `agent:codex:${driverId}`,
agentId: "codex",
},
}),
},
},
match: {
conversationId: "123",
},
record: {
bindingId: "binding:123",
targetSessionKey: `agent:codex:${driverId}`,
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "123",
},
status: "active",
boundAt: 0,
},
statefulTarget: {
kind: "stateful",
driverId,
sessionKey: `agent:codex:${driverId}`,
agentId: "codex",
},
};
}
afterEach(() => {
unregisterStatefulBindingTargetDriver("test-driver");
});
describe("binding target drivers", () => {
it("delegates ensureReady and ensureSession to the resolved driver", async () => {
const ensureReady = vi.fn(async () => ({ ok: true as const }));
const ensureSession = vi.fn(async () => ({
ok: true as const,
sessionKey: "agent:codex:test-driver",
}));
const driver: StatefulBindingTargetDriver = {
id: "test-driver",
ensureReady,
ensureSession,
};
registerStatefulBindingTargetDriver(driver);
const bindingResolution = createBindingResolution("test-driver");
await expect(
ensureConfiguredBindingTargetReady({
cfg: {} as never,
bindingResolution,
}),
).resolves.toEqual({ ok: true });
await expect(
ensureConfiguredBindingTargetSession({
cfg: {} as never,
bindingResolution,
}),
).resolves.toEqual({
ok: true,
sessionKey: "agent:codex:test-driver",
});
expect(ensureReady).toHaveBeenCalledTimes(1);
expect(ensureReady).toHaveBeenCalledWith({
cfg: {} as never,
bindingResolution,
});
expect(ensureSession).toHaveBeenCalledTimes(1);
expect(ensureSession).toHaveBeenCalledWith({
cfg: {} as never,
bindingResolution,
});
});
it("resolves resetInPlace through the driver session-key lookup", async () => {
const resetInPlace = vi.fn(async () => ({ ok: true as const }));
const driver: StatefulBindingTargetDriver = {
id: "test-driver",
ensureReady: async () => ({ ok: true }),
ensureSession: async () => ({
ok: true,
sessionKey: "agent:codex:test-driver",
}),
resolveTargetBySessionKey: ({ sessionKey }) => ({
kind: "stateful",
driverId: "test-driver",
sessionKey,
agentId: "codex",
}),
resetInPlace,
};
registerStatefulBindingTargetDriver(driver);
await expect(
resetConfiguredBindingTargetInPlace({
cfg: {} as never,
sessionKey: "agent:codex:test-driver",
reason: "reset",
}),
).resolves.toEqual({ ok: true });
expect(resetInPlace).toHaveBeenCalledTimes(1);
expect(resetInPlace).toHaveBeenCalledWith({
cfg: {} as never,
sessionKey: "agent:codex:test-driver",
reason: "reset",
bindingTarget: {
kind: "stateful",
driverId: "test-driver",
sessionKey: "agent:codex:test-driver",
agentId: "codex",
},
});
});
it("returns a typed error when no driver is registered", async () => {
const bindingResolution = createBindingResolution("missing-driver");
await expect(
ensureConfiguredBindingTargetReady({
cfg: {} as never,
bindingResolution,
}),
).resolves.toEqual({
ok: false,
error: "Configured binding target driver unavailable: missing-driver",
});
await expect(
ensureConfiguredBindingTargetSession({
cfg: {} as never,
bindingResolution,
}),
).resolves.toEqual({
ok: false,
sessionKey: "agent:codex:missing-driver",
error: "Configured binding target driver unavailable: missing-driver",
});
});
});

View File

@@ -0,0 +1,69 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ConfiguredBindingResolution } from "./binding-types.js";
import { ensureStatefulTargetBuiltinsRegistered } from "./stateful-target-builtins.js";
import {
getStatefulBindingTargetDriver,
resolveStatefulBindingTargetBySessionKey,
} from "./stateful-target-drivers.js";
export async function ensureConfiguredBindingTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
ensureStatefulTargetBuiltinsRegistered();
if (!params.bindingResolution) {
return { ok: true };
}
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
if (!driver) {
return {
ok: false,
error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`,
};
}
return await driver.ensureReady({
cfg: params.cfg,
bindingResolution: params.bindingResolution,
});
}
export async function resetConfiguredBindingTargetInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
reason: "new" | "reset";
}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> {
ensureStatefulTargetBuiltinsRegistered();
const resolved = resolveStatefulBindingTargetBySessionKey({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
if (!resolved?.driver.resetInPlace) {
return {
ok: false,
skipped: true,
};
}
return await resolved.driver.resetInPlace({
...params,
bindingTarget: resolved.bindingTarget,
});
}
export async function ensureConfiguredBindingTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> {
ensureStatefulTargetBuiltinsRegistered();
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
if (!driver) {
return {
ok: false,
sessionKey: params.bindingResolution.statefulTarget.sessionKey,
error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`,
};
}
return await driver.ensureSession({
cfg: params.cfg,
bindingResolution: params.bindingResolution,
});
}

View File

@@ -0,0 +1,53 @@
import type { AgentBinding } from "../../config/types.js";
import type {
ConversationRef,
SessionBindingRecord,
} from "../../infra/outbound/session-binding-service.js";
import type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
ChannelConfiguredBindingProvider,
} from "./types.adapters.js";
import type { ChannelId } from "./types.js";
export type ConfiguredBindingConversation = ConversationRef;
export type ConfiguredBindingChannel = ChannelId;
export type ConfiguredBindingRuleConfig = AgentBinding;
export type StatefulBindingTargetDescriptor = {
kind: "stateful";
driverId: string;
sessionKey: string;
agentId: string;
label?: string;
};
export type ConfiguredBindingRecordResolution = {
record: SessionBindingRecord;
statefulTarget: StatefulBindingTargetDescriptor;
};
export type ConfiguredBindingTargetFactory = {
driverId: string;
materialize: (params: {
accountId: string;
conversation: ChannelConfiguredBindingConversationRef;
}) => ConfiguredBindingRecordResolution;
};
export type CompiledConfiguredBinding = {
channel: ConfiguredBindingChannel;
accountPattern?: string;
binding: ConfiguredBindingRuleConfig;
bindingConversationId: string;
target: ChannelConfiguredBindingConversationRef;
agentId: string;
provider: ChannelConfiguredBindingProvider;
targetFactory: ConfiguredBindingTargetFactory;
};
export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & {
conversation: ConfiguredBindingConversation;
compiledBinding: CompiledConfiguredBinding;
match: ChannelConfiguredBindingMatch;
};

View File

@@ -0,0 +1,13 @@
import { acpConfiguredBindingConsumer } from "./acp-configured-binding-consumer.js";
import {
registerConfiguredBindingConsumer,
unregisterConfiguredBindingConsumer,
} from "./configured-binding-consumers.js";
export function ensureConfiguredBindingBuiltinsRegistered(): void {
registerConfiguredBindingConsumer(acpConfiguredBindingConsumer);
}
export function resetConfiguredBindingBuiltinsForTesting(): void {
unregisterConfiguredBindingConsumer(acpConfiguredBindingConsumer.id);
}

View File

@@ -0,0 +1,240 @@
import { listConfiguredBindings } from "../../config/bindings.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js";
import { pickFirstExistingAgentId } from "../../routing/resolve-route.js";
import { resolveChannelConfiguredBindingProvider } from "./binding-provider.js";
import type { CompiledConfiguredBinding, ConfiguredBindingChannel } from "./binding-types.js";
import { resolveConfiguredBindingConsumer } from "./configured-binding-consumers.js";
import { getChannelPlugin } from "./index.js";
import type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingProvider,
} from "./types.adapters.js";
// Configured bindings are channel-owned rules compiled from config, separate
// from runtime plugin-owned conversation bindings.
type ChannelPluginLike = NonNullable<ReturnType<typeof getChannelPlugin>>;
export type CompiledConfiguredBindingRegistry = {
rulesByChannel: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>;
};
type CachedCompiledConfiguredBindingRegistry = {
registryVersion: number;
registry: CompiledConfiguredBindingRegistry;
};
const compiledRegistryCache = new WeakMap<
OpenClawConfig,
CachedCompiledConfiguredBindingRegistry
>();
function findChannelPlugin(params: {
registry:
| {
channels?: Array<{ plugin?: ChannelPluginLike | null } | null> | null;
}
| null
| undefined;
channel: string;
}): ChannelPluginLike | undefined {
return (
params.registry?.channels?.find((entry) => entry?.plugin?.id === params.channel)?.plugin ??
undefined
);
}
function resolveLoadedChannelPlugin(channel: string) {
const normalized = channel.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const current = getChannelPlugin(normalized as ConfiguredBindingChannel);
if (current) {
return current;
}
return findChannelPlugin({
registry: getActivePluginRegistry(),
channel: normalized,
});
}
function resolveConfiguredBindingAdapter(channel: string): {
channel: ConfiguredBindingChannel;
provider: ChannelConfiguredBindingProvider;
} | null {
const normalized = channel.trim().toLowerCase();
if (!normalized) {
return null;
}
const plugin = resolveLoadedChannelPlugin(normalized);
const provider = resolveChannelConfiguredBindingProvider(plugin);
if (
!plugin ||
!provider ||
!provider.compileConfiguredBinding ||
!provider.matchInboundConversation
) {
return null;
}
return {
channel: plugin.id,
provider,
};
}
function resolveBindingConversationId(binding: {
match?: { peer?: { id?: string } };
}): string | null {
const id = binding.match?.peer?.id?.trim();
return id ? id : null;
}
function compileConfiguredBindingTarget(params: {
provider: ChannelConfiguredBindingProvider;
binding: CompiledConfiguredBinding["binding"];
conversationId: string;
}): ChannelConfiguredBindingConversationRef | null {
return params.provider.compileConfiguredBinding({
binding: params.binding,
conversationId: params.conversationId,
});
}
function compileConfiguredBindingRule(params: {
cfg: OpenClawConfig;
channel: ConfiguredBindingChannel;
binding: CompiledConfiguredBinding["binding"];
target: ChannelConfiguredBindingConversationRef;
bindingConversationId: string;
provider: ChannelConfiguredBindingProvider;
}): CompiledConfiguredBinding | null {
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
const consumer = resolveConfiguredBindingConsumer(params.binding);
if (!consumer) {
return null;
}
const targetFactory = consumer.buildTargetFactory({
cfg: params.cfg,
binding: params.binding,
channel: params.channel,
agentId,
target: params.target,
bindingConversationId: params.bindingConversationId,
});
if (!targetFactory) {
return null;
}
return {
channel: params.channel,
accountPattern: params.binding.match.accountId?.trim() || undefined,
binding: params.binding,
bindingConversationId: params.bindingConversationId,
target: params.target,
agentId,
provider: params.provider,
targetFactory,
};
}
function pushCompiledRule(
target: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>,
rule: CompiledConfiguredBinding,
) {
const existing = target.get(rule.channel);
if (existing) {
existing.push(rule);
return;
}
target.set(rule.channel, [rule]);
}
function compileConfiguredBindingRegistry(params: {
cfg: OpenClawConfig;
}): CompiledConfiguredBindingRegistry {
const rulesByChannel = new Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>();
for (const binding of listConfiguredBindings(params.cfg)) {
const bindingConversationId = resolveBindingConversationId(binding);
if (!bindingConversationId) {
continue;
}
const resolvedChannel = resolveConfiguredBindingAdapter(binding.match.channel);
if (!resolvedChannel) {
continue;
}
const target = compileConfiguredBindingTarget({
provider: resolvedChannel.provider,
binding,
conversationId: bindingConversationId,
});
if (!target) {
continue;
}
const rule = compileConfiguredBindingRule({
cfg: params.cfg,
channel: resolvedChannel.channel,
binding,
target,
bindingConversationId,
provider: resolvedChannel.provider,
});
if (!rule) {
continue;
}
pushCompiledRule(rulesByChannel, rule);
}
return {
rulesByChannel,
};
}
export function resolveCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
const registryVersion = getActivePluginRegistryVersion();
const cached = compiledRegistryCache.get(cfg);
if (cached?.registryVersion === registryVersion) {
return cached.registry;
}
const registry = compileConfiguredBindingRegistry({
cfg,
});
compiledRegistryCache.set(cfg, {
registryVersion,
registry,
});
return registry;
}
export function primeCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
const registry = compileConfiguredBindingRegistry({ cfg });
compiledRegistryCache.set(cfg, {
registryVersion: getActivePluginRegistryVersion(),
registry,
});
return registry;
}
export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): {
bindingCount: number;
channelCount: number;
} {
return {
bindingCount: [...registry.rulesByChannel.values()].reduce(
(sum, rules) => sum + rules.length,
0,
),
channelCount: registry.rulesByChannel.size,
};
}

View File

@@ -0,0 +1,69 @@
import type { OpenClawConfig } from "../../config/config.js";
import type {
CompiledConfiguredBinding,
ConfiguredBindingRecordResolution,
ConfiguredBindingRuleConfig,
ConfiguredBindingTargetFactory,
} from "./binding-types.js";
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
export type ParsedConfiguredBindingSessionKey = {
channel: string;
accountId: string;
};
export type ConfiguredBindingConsumer = {
id: string;
supports: (binding: ConfiguredBindingRuleConfig) => boolean;
buildTargetFactory: (params: {
cfg: OpenClawConfig;
binding: ConfiguredBindingRuleConfig;
channel: string;
agentId: string;
target: ChannelConfiguredBindingConversationRef;
bindingConversationId: string;
}) => ConfiguredBindingTargetFactory | null;
parseSessionKey?: (params: { sessionKey: string }) => ParsedConfiguredBindingSessionKey | null;
matchesSessionKey?: (params: {
sessionKey: string;
compiledBinding: CompiledConfiguredBinding;
accountId: string;
materializedTarget: ConfiguredBindingRecordResolution;
}) => boolean;
};
const registeredConfiguredBindingConsumers = new Map<string, ConfiguredBindingConsumer>();
export function listConfiguredBindingConsumers(): ConfiguredBindingConsumer[] {
return [...registeredConfiguredBindingConsumers.values()];
}
export function resolveConfiguredBindingConsumer(
binding: ConfiguredBindingRuleConfig,
): ConfiguredBindingConsumer | null {
for (const consumer of listConfiguredBindingConsumers()) {
if (consumer.supports(binding)) {
return consumer;
}
}
return null;
}
export function registerConfiguredBindingConsumer(consumer: ConfiguredBindingConsumer): void {
const id = consumer.id.trim();
if (!id) {
throw new Error("Configured binding consumer id is required");
}
const existing = registeredConfiguredBindingConsumers.get(id);
if (existing) {
return;
}
registeredConfiguredBindingConsumers.set(id, {
...consumer,
id,
});
}
export function unregisterConfiguredBindingConsumer(id: string): void {
registeredConfiguredBindingConsumers.delete(id.trim());
}

View File

@@ -0,0 +1,116 @@
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import type {
CompiledConfiguredBinding,
ConfiguredBindingChannel,
ConfiguredBindingRecordResolution,
} from "./binding-types.js";
import type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
} from "./types.adapters.js";
export function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
const trimmed = (match ?? "").trim();
if (!trimmed) {
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
}
if (trimmed === "*") {
return 1;
}
return normalizeAccountId(trimmed) === actual ? 2 : 0;
}
function matchCompiledBindingConversation(params: {
rule: CompiledConfiguredBinding;
conversationId: string;
parentConversationId?: string;
}): ChannelConfiguredBindingMatch | null {
return params.rule.provider.matchInboundConversation({
binding: params.rule.binding,
compiledBinding: params.rule.target,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
}
export function resolveCompiledBindingChannel(raw: string): ConfiguredBindingChannel | null {
const normalized = raw.trim().toLowerCase();
return normalized ? (normalized as ConfiguredBindingChannel) : null;
}
export function toConfiguredBindingConversationRef(conversation: ConversationRef): {
channel: ConfiguredBindingChannel;
accountId: string;
conversationId: string;
parentConversationId?: string;
} | null {
const channel = resolveCompiledBindingChannel(conversation.channel);
const conversationId = conversation.conversationId.trim();
if (!channel || !conversationId) {
return null;
}
return {
channel,
accountId: normalizeAccountId(conversation.accountId),
conversationId,
parentConversationId: conversation.parentConversationId?.trim() || undefined,
};
}
export function materializeConfiguredBindingRecord(params: {
rule: CompiledConfiguredBinding;
accountId: string;
conversation: ChannelConfiguredBindingConversationRef;
}): ConfiguredBindingRecordResolution {
return params.rule.targetFactory.materialize({
accountId: normalizeAccountId(params.accountId),
conversation: params.conversation,
});
}
export function resolveMatchingConfiguredBinding(params: {
rules: CompiledConfiguredBinding[];
conversation: ReturnType<typeof toConfiguredBindingConversationRef>;
}): { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null {
if (!params.conversation) {
return null;
}
let wildcardMatch: {
rule: CompiledConfiguredBinding;
match: ChannelConfiguredBindingMatch;
} | null = null;
let exactMatch: { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null =
null;
for (const rule of params.rules) {
const accountMatchPriority = resolveAccountMatchPriority(
rule.accountPattern,
params.conversation.accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const match = matchCompiledBindingConversation({
rule,
conversationId: params.conversation.conversationId,
parentConversationId: params.conversation.parentConversationId,
});
if (!match) {
continue;
}
const matchPriority = match.matchPriority ?? 0;
if (accountMatchPriority === 2) {
if (!exactMatch || matchPriority > (exactMatch.match.matchPriority ?? 0)) {
exactMatch = { rule, match };
}
continue;
}
if (!wildcardMatch || matchPriority > (wildcardMatch.match.matchPriority ?? 0)) {
wildcardMatch = { rule, match };
}
}
return exactMatch ?? wildcardMatch;
}

View File

@@ -0,0 +1,116 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
import type {
ConfiguredBindingRecordResolution,
ConfiguredBindingResolution,
} from "./binding-types.js";
import {
countCompiledBindingRegistry,
primeCompiledBindingRegistry,
resolveCompiledBindingRegistry,
} from "./configured-binding-compiler.js";
import {
materializeConfiguredBindingRecord,
resolveMatchingConfiguredBinding,
toConfiguredBindingConversationRef,
} from "./configured-binding-match.js";
import { resolveConfiguredBindingRecordBySessionKeyFromRegistry } from "./configured-binding-session-lookup.js";
export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): {
bindingCount: number;
channelCount: number;
} {
return countCompiledBindingRegistry(primeCompiledBindingRegistry(params.cfg));
}
export function resolveConfiguredBindingRecord(params: {
cfg: OpenClawConfig;
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
}): ConfiguredBindingRecordResolution | null {
const conversation = toConfiguredBindingConversationRef({
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
});
if (!conversation) {
return null;
}
return resolveConfiguredBindingRecordForConversation({
cfg: params.cfg,
conversation,
});
}
export function resolveConfiguredBindingRecordForConversation(params: {
cfg: OpenClawConfig;
conversation: ConversationRef;
}): ConfiguredBindingRecordResolution | null {
const conversation = toConfiguredBindingConversationRef(params.conversation);
if (!conversation) {
return null;
}
const registry = resolveCompiledBindingRegistry(params.cfg);
const rules = registry.rulesByChannel.get(conversation.channel);
if (!rules || rules.length === 0) {
return null;
}
const resolved = resolveMatchingConfiguredBinding({
rules,
conversation,
});
if (!resolved) {
return null;
}
return materializeConfiguredBindingRecord({
rule: resolved.rule,
accountId: conversation.accountId,
conversation: resolved.match,
});
}
export function resolveConfiguredBinding(params: {
cfg: OpenClawConfig;
conversation: ConversationRef;
}): ConfiguredBindingResolution | null {
const conversation = toConfiguredBindingConversationRef(params.conversation);
if (!conversation) {
return null;
}
const registry = resolveCompiledBindingRegistry(params.cfg);
const rules = registry.rulesByChannel.get(conversation.channel);
if (!rules || rules.length === 0) {
return null;
}
const resolved = resolveMatchingConfiguredBinding({
rules,
conversation,
});
if (!resolved) {
return null;
}
const materializedTarget = materializeConfiguredBindingRecord({
rule: resolved.rule,
accountId: conversation.accountId,
conversation: resolved.match,
});
return {
conversation,
compiledBinding: resolved.rule,
match: resolved.match,
...materializedTarget,
};
}
export function resolveConfiguredBindingRecordBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): ConfiguredBindingRecordResolution | null {
return resolveConfiguredBindingRecordBySessionKeyFromRegistry({
registry: resolveCompiledBindingRegistry(params.cfg),
sessionKey: params.sessionKey,
});
}

View File

@@ -0,0 +1,74 @@
import type { ConfiguredBindingRecordResolution } from "./binding-types.js";
import type { CompiledConfiguredBindingRegistry } from "./configured-binding-compiler.js";
import { listConfiguredBindingConsumers } from "./configured-binding-consumers.js";
import {
materializeConfiguredBindingRecord,
resolveAccountMatchPriority,
resolveCompiledBindingChannel,
} from "./configured-binding-match.js";
export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: {
registry: CompiledConfiguredBindingRegistry;
sessionKey: string;
}): ConfiguredBindingRecordResolution | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
for (const consumer of listConfiguredBindingConsumers()) {
const parsed = consumer.parseSessionKey?.({ sessionKey });
if (!parsed) {
continue;
}
const channel = resolveCompiledBindingChannel(parsed.channel);
if (!channel) {
continue;
}
const rules = params.registry.rulesByChannel.get(channel);
if (!rules || rules.length === 0) {
continue;
}
let wildcardMatch: ConfiguredBindingRecordResolution | null = null;
let exactMatch: ConfiguredBindingRecordResolution | null = null;
for (const rule of rules) {
if (rule.targetFactory.driverId !== consumer.id) {
continue;
}
const accountMatchPriority = resolveAccountMatchPriority(
rule.accountPattern,
parsed.accountId,
);
if (accountMatchPriority === 0) {
continue;
}
const materializedTarget = materializeConfiguredBindingRecord({
rule,
accountId: parsed.accountId,
conversation: rule.target,
});
const matchesSessionKey =
consumer.matchesSessionKey?.({
sessionKey,
compiledBinding: rule,
accountId: parsed.accountId,
materializedTarget,
}) ?? materializedTarget.record.targetSessionKey === sessionKey;
if (matchesSessionKey) {
if (accountMatchPriority === 2) {
exactMatch = materializedTarget;
break;
}
wildcardMatch = materializedTarget;
}
}
if (exactMatch) {
return exactMatch;
}
if (wildcardMatch) {
return wildcardMatch;
}
}
return null;
}

View File

@@ -0,0 +1,13 @@
import { acpStatefulBindingTargetDriver } from "./acp-stateful-target-driver.js";
import {
registerStatefulBindingTargetDriver,
unregisterStatefulBindingTargetDriver,
} from "./stateful-target-drivers.js";
export function ensureStatefulTargetBuiltinsRegistered(): void {
registerStatefulBindingTargetDriver(acpStatefulBindingTargetDriver);
}
export function resetStatefulTargetBuiltinsForTesting(): void {
unregisterStatefulBindingTargetDriver(acpStatefulBindingTargetDriver.id);
}

View File

@@ -0,0 +1,89 @@
import type { OpenClawConfig } from "../../config/config.js";
import type {
ConfiguredBindingResolution,
StatefulBindingTargetDescriptor,
} from "./binding-types.js";
export type StatefulBindingTargetReadyResult = { ok: true } | { ok: false; error: string };
export type StatefulBindingTargetSessionResult =
| { ok: true; sessionKey: string }
| { ok: false; sessionKey: string; error: string };
export type StatefulBindingTargetResetResult =
| { ok: true }
| { ok: false; skipped?: boolean; error?: string };
export type StatefulBindingTargetDriver = {
id: string;
ensureReady: (params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}) => Promise<StatefulBindingTargetReadyResult>;
ensureSession: (params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}) => Promise<StatefulBindingTargetSessionResult>;
resolveTargetBySessionKey?: (params: {
cfg: OpenClawConfig;
sessionKey: string;
}) => StatefulBindingTargetDescriptor | null;
resetInPlace?: (params: {
cfg: OpenClawConfig;
sessionKey: string;
bindingTarget: StatefulBindingTargetDescriptor;
reason: "new" | "reset";
}) => Promise<StatefulBindingTargetResetResult>;
};
const registeredStatefulBindingTargetDrivers = new Map<string, StatefulBindingTargetDriver>();
function listStatefulBindingTargetDrivers(): StatefulBindingTargetDriver[] {
return [...registeredStatefulBindingTargetDrivers.values()];
}
export function registerStatefulBindingTargetDriver(driver: StatefulBindingTargetDriver): void {
const id = driver.id.trim();
if (!id) {
throw new Error("Stateful binding target driver id is required");
}
const normalized = { ...driver, id };
const existing = registeredStatefulBindingTargetDrivers.get(id);
if (existing) {
return;
}
registeredStatefulBindingTargetDrivers.set(id, normalized);
}
export function unregisterStatefulBindingTargetDriver(id: string): void {
registeredStatefulBindingTargetDrivers.delete(id.trim());
}
export function getStatefulBindingTargetDriver(id: string): StatefulBindingTargetDriver | null {
const normalizedId = id.trim();
if (!normalizedId) {
return null;
}
return registeredStatefulBindingTargetDrivers.get(normalizedId) ?? null;
}
export function resolveStatefulBindingTargetBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): { driver: StatefulBindingTargetDriver; bindingTarget: StatefulBindingTargetDescriptor } | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
for (const driver of listStatefulBindingTargetDrivers()) {
const bindingTarget = driver.resolveTargetBySessionKey?.({
cfg: params.cfg,
sessionKey,
});
if (bindingTarget) {
return {
driver,
bindingTarget,
};
}
}
return null;
}

View File

@@ -1,6 +1,6 @@
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ConfiguredBindingRule } from "../../config/bindings.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { AgentAcpBinding } from "../../config/types.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
@@ -541,24 +541,26 @@ export type ChannelAllowlistAdapter = {
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
};
export type ChannelAcpBindingAdapter = {
normalizeConfiguredBindingTarget?: (params: {
binding: AgentAcpBinding;
export type ChannelConfiguredBindingConversationRef = {
conversationId: string;
parentConversationId?: string;
};
export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingConversationRef & {
matchPriority?: number;
};
export type ChannelConfiguredBindingProvider = {
compileConfiguredBinding: (params: {
binding: ConfiguredBindingRule;
conversationId: string;
}) => {
}) => ChannelConfiguredBindingConversationRef | null;
matchInboundConversation: (params: {
binding: ConfiguredBindingRule;
compiledBinding: ChannelConfiguredBindingConversationRef;
conversationId: string;
parentConversationId?: string;
} | null;
matchConfiguredBinding?: (params: {
binding: AgentAcpBinding;
bindingConversationId: string;
conversationId: string;
parentConversationId?: string;
}) => {
conversationId: string;
parentConversationId?: string;
matchPriority?: number;
} | null;
}) => ChannelConfiguredBindingMatch | null;
};
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {

View File

@@ -17,7 +17,7 @@ import type {
ChannelSetupAdapter,
ChannelStatusAdapter,
ChannelAllowlistAdapter,
ChannelAcpBindingAdapter,
ChannelConfiguredBindingProvider,
} from "./types.adapters.js";
import type {
ChannelAgentTool,
@@ -78,7 +78,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
lifecycle?: ChannelLifecycleAdapter;
execApprovals?: ChannelExecApprovalAdapter;
allowlist?: ChannelAllowlistAdapter;
acpBindings?: ChannelAcpBindingAdapter;
bindings?: ChannelConfiguredBindingProvider;
streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter;

View File

@@ -33,7 +33,9 @@ export type {
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelAllowlistAdapter,
ChannelAcpBindingAdapter,
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
ChannelConfiguredBindingProvider,
ChannelPairingAdapter,
ChannelSecurityAdapter,
ChannelSetupAdapter,

View File

@@ -96,6 +96,30 @@ describe("buildGatewayInstallPlan", () => {
expect(plan.workingDirectory).toBe("/Users/me");
expect(plan.environment).toEqual({ OPENCLAW_PORT: "3000" });
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith(
expect.objectContaining({
env: {},
port: 3000,
extraPathDirs: ["/custom"],
}),
);
});
it("does not prepend '.' when nodePath is a bare executable name", async () => {
mockNodeGatewayPlanFixture();
await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
nodePath: "node",
});
expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith(
expect.objectContaining({
extraPathDirs: undefined,
}),
);
});
it("emits warnings when renderSystemNodeWarning returns one", async () => {

View File

@@ -11,6 +11,7 @@ import { buildServiceEnvironment } from "../daemon/service-env.js";
import {
emitDaemonInstallRuntimeWarning,
resolveDaemonInstallRuntimeInputs,
resolveDaemonNodeBinDir,
} from "./daemon-install-plan.shared.js";
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
@@ -87,6 +88,9 @@ export async function buildGatewayInstallPlan(params: {
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
: undefined,
// Keep npm/pnpm available to the service when the selected daemon node comes from
// a version-manager bin directory that isn't covered by static PATH guesses.
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
});
// Merge config env vars into the service environment (vars + inline env keys).

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
resolveDaemonInstallRuntimeInputs,
resolveDaemonNodeBinDir,
resolveGatewayDevMode,
} from "./daemon-install-plan.shared.js";
@@ -29,3 +30,13 @@ describe("resolveDaemonInstallRuntimeInputs", () => {
});
});
});
describe("resolveDaemonNodeBinDir", () => {
it("returns the absolute node bin directory", () => {
expect(resolveDaemonNodeBinDir("/custom/node/bin/node")).toEqual(["/custom/node/bin"]);
});
it("ignores bare executable names", () => {
expect(resolveDaemonNodeBinDir("node")).toBeUndefined();
});
});

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import {
emitNodeRuntimeWarning,
@@ -42,3 +43,11 @@ export async function emitDaemonInstallRuntimeWarning(params: {
title: params.title,
});
}
export function resolveDaemonNodeBinDir(nodePath?: string): string[] | undefined {
const trimmed = nodePath?.trim();
if (!trimmed || !path.isAbsolute(trimmed)) {
return undefined;
}
return [path.dirname(trimmed)];
}

View File

@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
resolvePreferredNodePath: vi.fn(),
resolveNodeProgramArguments: vi.fn(),
resolveSystemNodeInfo: vi.fn(),
renderSystemNodeWarning: vi.fn(),
buildNodeServiceEnvironment: vi.fn(),
}));
vi.mock("../daemon/runtime-paths.js", () => ({
resolvePreferredNodePath: mocks.resolvePreferredNodePath,
resolveSystemNodeInfo: mocks.resolveSystemNodeInfo,
renderSystemNodeWarning: mocks.renderSystemNodeWarning,
}));
vi.mock("../daemon/program-args.js", () => ({
resolveNodeProgramArguments: mocks.resolveNodeProgramArguments,
}));
vi.mock("../daemon/service-env.js", () => ({
buildNodeServiceEnvironment: mocks.buildNodeServiceEnvironment,
}));
import { buildNodeInstallPlan } from "./node-daemon-install-helpers.js";
afterEach(() => {
vi.resetAllMocks();
});
describe("buildNodeInstallPlan", () => {
it("passes the selected node bin directory into the node service environment", async () => {
mocks.resolveNodeProgramArguments.mockResolvedValue({
programArguments: ["node", "node-host"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node/bin/node",
version: "22.0.0",
supported: true,
});
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
mocks.buildNodeServiceEnvironment.mockReturnValue({
OPENCLAW_SERVICE_VERSION: "2026.3.14",
});
const plan = await buildNodeInstallPlan({
env: {},
host: "127.0.0.1",
port: 18789,
runtime: "node",
nodePath: "/custom/node/bin/node",
});
expect(plan.environment).toEqual({
OPENCLAW_SERVICE_VERSION: "2026.3.14",
});
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
env: {},
extraPathDirs: ["/custom/node/bin"],
});
});
it("does not prepend '.' when nodePath is a bare executable name", async () => {
mocks.resolveNodeProgramArguments.mockResolvedValue({
programArguments: ["node", "node-host"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/usr/bin/node",
version: "22.0.0",
supported: true,
});
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
mocks.buildNodeServiceEnvironment.mockReturnValue({
OPENCLAW_SERVICE_VERSION: "2026.3.14",
});
await buildNodeInstallPlan({
env: {},
host: "127.0.0.1",
port: 18789,
runtime: "node",
nodePath: "node",
});
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
env: {},
extraPathDirs: undefined,
});
});
});

View File

@@ -4,6 +4,7 @@ import { buildNodeServiceEnvironment } from "../daemon/service-env.js";
import {
emitDaemonInstallRuntimeWarning,
resolveDaemonInstallRuntimeInputs,
resolveDaemonNodeBinDir,
} from "./daemon-install-plan.shared.js";
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
import type { NodeDaemonRuntime } from "./node-daemon-runtime.js";
@@ -54,7 +55,12 @@ export async function buildNodeInstallPlan(params: {
title: "Node daemon runtime",
});
const environment = buildNodeServiceEnvironment({ env: params.env });
const environment = buildNodeServiceEnvironment({
env: params.env,
// Match the gateway install path so supervised node services keep the chosen
// node toolchain on PATH for sibling binaries like npm/pnpm when needed.
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
});
const description = formatNodeServiceDescription({
version: environment.OPENCLAW_SERVICE_VERSION,
});

View File

@@ -1,6 +1,8 @@
import type { OpenClawConfig } from "./config.js";
import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js";
export type ConfiguredBindingRule = AgentBinding;
function normalizeBindingType(binding: AgentBinding): "route" | "acp" {
return binding.type === "acp" ? "acp" : "route";
}

View File

@@ -3,7 +3,9 @@ import fsPromises from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { upsertAcpSessionMeta } from "../../acp/runtime/session-meta.js";
import * as jsonFiles from "../../infra/json-files.js";
import type { OpenClawConfig } from "../config.js";
import {
clearSessionStoreCacheForTest,
loadSessionStore,
@@ -279,6 +281,72 @@ describe("session store lock (Promise chain mutex)", () => {
expect(store[key]?.modelProvider).toBeUndefined();
expect(store[key]?.model).toBeUndefined();
});
it("preserves ACP metadata when replacing a session entry wholesale", async () => {
const key = "agent:codex:acp:binding:discord:default:feedface";
const acp = {
backend: "acpx",
agent: "codex",
runtimeSessionName: "codex-discord",
mode: "persistent" as const,
state: "idle" as const,
lastActivityAt: 100,
};
const { storePath } = await makeTmpStore({
[key]: {
sessionId: "sess-acp",
updatedAt: 100,
acp,
},
});
await updateSessionStore(storePath, (store) => {
store[key] = {
sessionId: "sess-acp",
updatedAt: 200,
modelProvider: "openai-codex",
model: "gpt-5.4",
};
});
const store = loadSessionStore(storePath);
expect(store[key]?.acp).toEqual(acp);
expect(store[key]?.modelProvider).toBe("openai-codex");
expect(store[key]?.model).toBe("gpt-5.4");
});
it("allows explicit ACP metadata removal through the ACP session helper", async () => {
const key = "agent:codex:acp:binding:discord:default:deadbeef";
const { storePath } = await makeTmpStore({
[key]: {
sessionId: "sess-acp-clear",
updatedAt: 100,
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "codex-discord",
mode: "persistent",
state: "idle",
lastActivityAt: 100,
},
},
});
const cfg = {
session: {
store: storePath,
},
} as OpenClawConfig;
const result = await upsertAcpSessionMeta({
cfg,
sessionKey: key,
mutate: () => null,
});
expect(result?.acp).toBeUndefined();
const store = loadSessionStore(storePath);
expect(store[key]?.acp).toBeUndefined();
});
});
describe("appendAssistantMessageToSessionTranscript", () => {

View File

@@ -309,6 +309,12 @@ type SaveSessionStoreOptions = {
skipMaintenance?: boolean;
/** Active session key for warn-only maintenance. */
activeSessionKey?: string;
/**
* Session keys that are allowed to drop persisted ACP metadata during this update.
* All other updates preserve existing `entry.acp` blocks when callers replace the
* whole session entry without carrying ACP state forward.
*/
allowDropAcpMetaSessionKeys?: string[];
/** Optional callback for warn-only maintenance. */
onWarn?: (warning: SessionMaintenanceWarning) => void | Promise<void>;
/** Optional callback with maintenance stats after a save. */
@@ -337,6 +343,64 @@ function updateSessionStoreWriteCaches(params: {
});
}
function resolveMutableSessionStoreKey(
store: Record<string, SessionEntry>,
sessionKey: string,
): string | undefined {
const trimmed = sessionKey.trim();
if (!trimmed) {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(store, trimmed)) {
return trimmed;
}
const normalized = normalizeStoreSessionKey(trimmed);
if (Object.prototype.hasOwnProperty.call(store, normalized)) {
return normalized;
}
return Object.keys(store).find((key) => normalizeStoreSessionKey(key) === normalized);
}
function collectAcpMetadataSnapshot(
store: Record<string, SessionEntry>,
): Map<string, NonNullable<SessionEntry["acp"]>> {
const snapshot = new Map<string, NonNullable<SessionEntry["acp"]>>();
for (const [sessionKey, entry] of Object.entries(store)) {
if (entry?.acp) {
snapshot.set(sessionKey, entry.acp);
}
}
return snapshot;
}
function preserveExistingAcpMetadata(params: {
previousAcpByKey: Map<string, NonNullable<SessionEntry["acp"]>>;
nextStore: Record<string, SessionEntry>;
allowDropSessionKeys?: string[];
}): void {
const allowDrop = new Set(
(params.allowDropSessionKeys ?? []).map((key) => normalizeStoreSessionKey(key)),
);
for (const [previousKey, previousAcp] of params.previousAcpByKey.entries()) {
const normalizedKey = normalizeStoreSessionKey(previousKey);
if (allowDrop.has(normalizedKey)) {
continue;
}
const nextKey = resolveMutableSessionStoreKey(params.nextStore, previousKey);
if (!nextKey) {
continue;
}
const nextEntry = params.nextStore[nextKey];
if (!nextEntry || nextEntry.acp) {
continue;
}
params.nextStore[nextKey] = {
...nextEntry,
acp: previousAcp,
};
}
}
async function saveSessionStoreUnlocked(
storePath: string,
store: Record<string, SessionEntry>,
@@ -526,7 +590,13 @@ export async function updateSessionStore<T>(
return await withSessionStoreLock(storePath, async () => {
// Always re-read inside the lock to avoid clobbering concurrent writers.
const store = loadSessionStore(storePath, { skipCache: true });
const previousAcpByKey = collectAcpMetadataSnapshot(store);
const result = await mutator(store);
preserveExistingAcpMetadata({
previousAcpByKey,
nextStore: store,
allowDropSessionKeys: opts?.allowDropAcpMetaSessionKeys,
});
await saveSessionStoreUnlocked(storePath, store, opts);
return result;
});

View File

@@ -257,6 +257,18 @@ describe("buildMinimalServicePath", () => {
const unique = [...new Set(parts)];
expect(parts.length).toBe(unique.length);
});
it("prepends explicit runtime bin directories before guessed user paths", () => {
const result = buildMinimalServicePath({
platform: "linux",
extraDirs: ["/home/alice/.nvm/versions/node/v22.22.0/bin"],
env: { HOME: "/home/alice" },
});
const parts = splitPath(result, "linux");
expect(parts[0]).toBe("/home/alice/.nvm/versions/node/v22.22.0/bin");
expect(parts).toContain("/home/alice/.nvm/current/bin");
});
});
describe("buildServiceEnvironment", () => {
@@ -344,6 +356,19 @@ describe("buildServiceEnvironment", () => {
expect(env).not.toHaveProperty("PATH");
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway");
});
it("prepends extra runtime directories to the gateway service PATH", () => {
const env = buildServiceEnvironment({
env: { HOME: "/home/user" },
port: 18789,
platform: "linux",
extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"],
});
expect(env.PATH?.split(path.posix.delimiter)[0]).toBe(
"/home/user/.nvm/versions/node/v22.22.0/bin",
);
});
});
describe("buildNodeServiceEnvironment", () => {
@@ -416,6 +441,18 @@ describe("buildNodeServiceEnvironment", () => {
});
expect(env.TMPDIR).toBe(os.tmpdir());
});
it("prepends extra runtime directories to the node service PATH", () => {
const env = buildNodeServiceEnvironment({
env: { HOME: "/home/user" },
platform: "linux",
extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"],
});
expect(env.PATH?.split(path.posix.delimiter)[0]).toBe(
"/home/user/.nvm/versions/node/v22.22.0/bin",
);
});
});
describe("shared Node TLS env defaults", () => {

View File

@@ -247,10 +247,11 @@ export function buildServiceEnvironment(params: {
port: number;
launchdLabel?: string;
platform?: NodeJS.Platform;
extraPathDirs?: string[];
}): Record<string, string | undefined> {
const { env, port, launchdLabel } = params;
const { env, port, launchdLabel, extraPathDirs } = params;
const platform = params.platform ?? process.platform;
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs);
const profile = env.OPENCLAW_PROFILE;
const resolvedLaunchdLabel =
launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined);
@@ -271,10 +272,11 @@ export function buildServiceEnvironment(params: {
export function buildNodeServiceEnvironment(params: {
env: Record<string, string | undefined>;
platform?: NodeJS.Platform;
extraPathDirs?: string[];
}): Record<string, string | undefined> {
const { env } = params;
const { env, extraPathDirs } = params;
const platform = params.platform ?? process.platform;
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs);
const gatewayToken =
env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined;
return {
@@ -313,6 +315,7 @@ function buildCommonServiceEnvironment(
function resolveSharedServiceEnvironmentFields(
env: Record<string, string | undefined>,
platform: NodeJS.Platform,
extraPathDirs: string[] | undefined,
): SharedServiceEnvironmentFields {
const stateDir = env.OPENCLAW_STATE_DIR;
const configPath = env.OPENCLAW_CONFIG_PATH;
@@ -331,7 +334,10 @@ function resolveSharedServiceEnvironmentFields(
tmpDir,
// On Windows, Scheduled Tasks should inherit the current task PATH instead of
// freezing the install-time snapshot into gateway.cmd/node-host.cmd.
minimalPath: platform === "win32" ? undefined : buildMinimalServicePath({ env, platform }),
minimalPath:
platform === "win32"
? undefined
: buildMinimalServicePath({ env, platform, extraDirs: extraPathDirs }),
proxyEnv,
nodeCaCerts,
nodeUseSystemCa,

View File

@@ -6,6 +6,9 @@ import type { PluginDiagnostic } from "../plugins/types.js";
import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js";
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
const primeConfiguredBindingRegistry = vi.hoisted(() =>
vi.fn(() => ({ bindingCount: 0, channelCount: 0 })),
);
type HandleGatewayRequestOptions = GatewayRequestOptions & {
extraHandlers?: Record<string, unknown>;
};
@@ -17,6 +20,10 @@ vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
vi.mock("../channels/plugins/binding-registry.js", () => ({
primeConfiguredBindingRegistry,
}));
vi.mock("./server-methods.js", () => ({
handleGatewayRequest,
}));
@@ -51,6 +58,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
httpRoutes: [],
cliRegistrars: [],
services: [],
conversationBindingResolvedHandlers: [],
diagnostics,
});
@@ -110,6 +118,7 @@ async function createSubagentRuntime(
beforeEach(async () => {
loadOpenClawPlugins.mockReset();
primeConfiguredBindingRegistry.mockClear().mockReturnValue({ bindingCount: 0, channelCount: 0 });
handleGatewayRequest.mockReset();
const runtimeModule = await import("../plugins/runtime/index.js");
runtimeModule.clearGatewaySubagentRuntime();
@@ -440,6 +449,29 @@ describe("loadGatewayPlugins", () => {
);
});
test("primes configured bindings during gateway startup", async () => {
const { loadGatewayPlugins } = await importServerPluginsModule();
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const cfg = {};
loadGatewayPlugins({
cfg,
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
baseMethods: [],
});
expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg });
});
test("can suppress duplicate diagnostics when reloading full runtime plugins", async () => {
const { loadGatewayPlugins } = await importServerPluginsModule();
const diagnostics: PluginDiagnostic[] = [

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js";
import type { loadConfig } from "../config/config.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
@@ -416,6 +417,7 @@ export function loadGatewayPlugins(params: {
},
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
});
primeConfiguredBindingRegistry({ cfg: params.cfg });
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods]));
if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) {

View File

@@ -155,6 +155,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
});

View File

@@ -1,6 +1,43 @@
// Public pairing/session-binding helpers for plugins that manage conversation ownership.
// Public binding helpers for both runtime plugin-owned bindings and
// config-driven channel bindings.
export * from "../acp/persistent-bindings.route.js";
export {
createConversationBindingRecord,
getConversationBindingCapabilities,
listSessionBindingRecords,
resolveConversationBindingRecord,
touchConversationBindingRecord,
unbindConversationBindingRecord,
} from "../bindings/records.js";
export {
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
type ConfiguredBindingRouteResult,
} from "../channels/plugins/binding-routing.js";
export {
primeConfiguredBindingRegistry,
resolveConfiguredBinding,
resolveConfiguredBindingRecord,
resolveConfiguredBindingRecordBySessionKey,
resolveConfiguredBindingRecordForConversation,
} from "../channels/plugins/binding-registry.js";
export {
ensureConfiguredBindingTargetReady,
ensureConfiguredBindingTargetSession,
resetConfiguredBindingTargetInPlace,
} from "../channels/plugins/binding-targets.js";
export type {
ConfiguredBindingConversation,
ConfiguredBindingResolution,
CompiledConfiguredBinding,
StatefulBindingTargetDescriptor,
} from "../channels/plugins/binding-types.js";
export type {
StatefulBindingTargetDriver,
StatefulBindingTargetReadyResult,
StatefulBindingTargetResetResult,
StatefulBindingTargetSessionResult,
} from "../channels/plugins/stateful-target-drivers.js";
export {
type BindingStatus,
type BindingTargetKind,

View File

@@ -1,4 +1,8 @@
export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js";
export type {
ChannelAccountSnapshot,
ChannelGatewayContext,
ChannelMessageActionAdapter,
} from "../channels/plugins/types.js";
export type { OpenClawConfig } from "../config/config.js";
export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js";
export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js";
@@ -13,6 +17,11 @@ export type {
ThreadBindingRecord,
ThreadBindingTargetKind,
} from "../../extensions/discord/src/monitor/thread-bindings.js";
export type {
ChannelConfiguredBindingProvider,
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
} from "../channels/plugins/types.adapters.js";
export type {
ChannelMessageActionContext,
ChannelPlugin,

View File

@@ -31,6 +31,11 @@ export type {
ChannelMeta,
ChannelOutboundAdapter,
} from "../channels/plugins/types.js";
export type {
ChannelConfiguredBindingProvider,
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
} from "../channels/plugins/types.adapters.js";
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
export { createReplyPrefixContext } from "../channels/reply-prefix.js";
export { createTypingCallbacks } from "../channels/typing.js";

View File

@@ -1,9 +1,13 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import {
buildPluginSdkEntrySources,
buildPluginSdkPackageExports,
buildPluginSdkSpecifiers,
pluginSdkEntrypoints,
@@ -11,6 +15,9 @@ import {
import * as sdk from "./index.js";
const pluginSdkSpecifiers = buildPluginSdkSpecifiers();
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href;
describe("plugin-sdk exports", () => {
it("does not expose runtime modules", () => {
@@ -63,16 +70,33 @@ describe("plugin-sdk exports", () => {
});
it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => {
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-"));
const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-"));
const repoDistDir = path.join(process.cwd(), "dist");
try {
await expect(fs.access(path.join(repoDistDir, "plugin-sdk"))).resolves.toBeUndefined();
const buildScriptPath = path.join(fixtureDir, "build-plugin-sdk.mjs");
await fs.writeFile(
buildScriptPath,
`import { build } from ${JSON.stringify(tsdownModuleUrl)};
await build(${JSON.stringify({
clean: true,
config: false,
dts: false,
entry: buildPluginSdkEntrySources(),
env: { NODE_ENV: "production" },
fixedExtension: false,
logLevel: "error",
outDir,
platform: "node",
})});
`,
);
await execFileAsync(process.execPath, [buildScriptPath], {
cwd: process.cwd(),
});
for (const entry of pluginSdkEntrypoints) {
const module = await import(
pathToFileURL(path.join(repoDistDir, "plugin-sdk", `${entry}.js`)).href
);
const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href);
expect(module).toBeTypeOf("object");
}
@@ -80,8 +104,8 @@ describe("plugin-sdk exports", () => {
const consumerDir = path.join(fixtureDir, "consumer");
const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs");
await fs.mkdir(packageDir, { recursive: true });
await fs.symlink(repoDistDir, path.join(packageDir, "dist"), "dir");
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir");
await fs.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify(
@@ -114,6 +138,7 @@ describe("plugin-sdk exports", () => {
Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])),
);
} finally {
await fs.rm(outDir, { recursive: true, force: true });
await fs.rm(fixtureDir, { recursive: true, force: true });
}
});

View File

@@ -14,8 +14,25 @@ export type {
ChannelMessageActionName,
ChannelStatusIssue,
} from "../channels/plugins/types.js";
export type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
ChannelConfiguredBindingProvider,
} from "../channels/plugins/types.adapters.js";
export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type { ChannelSetupAdapter, ChannelSetupInput } from "../channels/plugins/types.js";
export type {
ConfiguredBindingConversation,
ConfiguredBindingResolution,
CompiledConfiguredBinding,
StatefulBindingTargetDescriptor,
} from "../channels/plugins/binding-types.js";
export type {
StatefulBindingTargetDriver,
StatefulBindingTargetReadyResult,
StatefulBindingTargetResetResult,
StatefulBindingTargetSessionResult,
} from "../channels/plugins/stateful-target-drivers.js";
export type {
ChannelSetupWizard,
ChannelSetupWizardAllowFromEntry,

View File

@@ -12,6 +12,11 @@ export type {
TelegramActionConfig,
TelegramNetworkConfig,
} from "../config/types.js";
export type {
ChannelConfiguredBindingProvider,
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingMatch,
} from "../channels/plugins/types.adapters.js";
export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js";
export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js";
export type { TelegramProbe } from "../../extensions/telegram/src/probe.js";
@@ -26,7 +31,6 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j
export { parseTelegramTopicConversation } from "../acp/conversation-id.js";
export { formatCliCommand } from "../cli/command-format.js";
export { formatDocsLink } from "../terminal/links.js";
export {
PAIRING_APPROVED_MESSAGE,
applyAccountNameToChannelSection,

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.

View File

@@ -35,6 +35,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
cliRegistrars: [],
services: [],
commands: [],
conversationBindingResolvedHandlers: [],
diagnostics: [],
});