fix(feishu): re-resolve route when dynamic agent binding already exists in runtime config (fixes #42837) (#92814)

* fix(feishu): re-resolve route when dynamic agent binding already exists in runtime config

When dynamicAgentCreation is enabled and a binding was previously written
to the config file (e.g. from a prior message), the in-memory cfg may be
stale and not contain the binding. Previously, maybeCreateDynamicAgent
returned { created: false, updatedCfg: cfg } with the stale cfg, and
bot.ts only re-resolved the route when created === true. This caused
subsequent messages to still route to agent:main.

Fix: check runtime.config.current() for the binding when it is missing
from the in-memory cfg. When found, return the runtime's current config
so the caller can re-resolve the route with up-to-date bindings.

Fixes #42837

* fix(feishu): serialize dynamic agent config updates

* fix(feishu): route with refreshed runtime config

* fix(feishu): use current dynamic-agent policy

* fix(feishu): reauthorize refreshed dynamic routes

* fix(feishu): authorize dynamic agent mutations

* fix(feishu): complete account-scoped dynamic routing

* fix(feishu): revalidate current direct routes

* fix(feishu): isolate named-account dynamic agents

* fix(feishu): bound named dynamic agent ids

* docs(feishu): explain legacy dynamic agent cap

* test(feishu): fix dynamic routing check types

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
liuhao1024
2026-06-14 17:28:54 +08:00
committed by GitHub
parent b9e8e6d66e
commit db5e415888
7 changed files with 1101 additions and 327 deletions

View File

@@ -163,8 +163,13 @@ function buildDefaultResolveRoute(): ResolvedAgentRoute {
matchedBy: "default",
};
}
let currentRuntimeConfig = {} as ClawdbotConfig;
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
return {
config: {
current: vi.fn(() => currentRuntimeConfig),
},
channel: {
routing: {
resolveAgentRoute: resolveAgentRouteMock,
@@ -413,7 +418,11 @@ afterAll(() => {
vi.resetModules();
});
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
async function dispatchMessage(params: {
cfg: ClawdbotConfig;
currentCfg?: ClawdbotConfig;
event: FeishuMessageEvent;
}) {
const runtime = createRuntimeEnv();
const feishuConfig = params.cfg.channels?.feishu;
const cfg =
@@ -429,6 +438,7 @@ async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessa
},
} as ClawdbotConfig)
: params.cfg;
currentRuntimeConfig = params.currentCfg ?? cfg;
await handleFeishuMessage({
cfg,
event: params.event,
@@ -455,7 +465,10 @@ describe("handleFeishuMessage ACP routing", () => {
mockTouchBinding.mockReset();
mockResolveFeishuReasoningPreviewEnabled.mockReset().mockReturnValue(false);
mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
mockMaybeCreateDynamicAgent.mockReset().mockResolvedValue({ created: false });
mockMaybeCreateDynamicAgent.mockReset().mockImplementation(async ({ cfg }) => ({
created: false,
updatedCfg: cfg,
}));
mockResolveAgentRoute.mockReset().mockReturnValue({
...buildDefaultResolveRoute(),
sessionKey: "agent:main:feishu:direct:ou_sender_1",
@@ -976,7 +989,9 @@ describe("handleFeishuMessage command authorization", () => {
},
);
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockShouldComputeCommandAuthorized = vi.fn(() => true);
const mockShouldComputeCommandAuthorized = vi.fn<
PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"]
>(() => true);
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
const mockBuildPairingReply = vi.fn(() => "Pairing response");
@@ -1009,7 +1024,10 @@ describe("handleFeishuMessage command authorization", () => {
mockResolveBoundConversation.mockReset().mockReturnValue(null);
mockTouchBinding.mockReset();
mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
mockMaybeCreateDynamicAgent.mockReset().mockResolvedValue({ created: false });
mockMaybeCreateDynamicAgent.mockReset().mockImplementation(async ({ cfg }) => ({
created: false,
updatedCfg: cfg,
}));
mockResolveAgentRoute.mockReturnValue(buildDefaultResolveRoute());
mockCreateFeishuClient.mockReturnValue({
contact: {
@@ -1216,7 +1234,7 @@ describe("handleFeishuMessage command authorization", () => {
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith("dispatch-complete-no-visible-reply");
});
it("passes disabled config-write policy to dynamic agent creation", async () => {
it("uses refreshed config for dynamic agent dispatch", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
@@ -1231,6 +1249,22 @@ describe("handleFeishuMessage command authorization", () => {
},
},
} as ClawdbotConfig;
const refreshedCfg = {
...cfg,
agents: {
list: [
{
id: "feishu-ou-attacker",
workspace: "/tmp/feishu-ou-attacker",
agentDir: "/tmp/feishu-ou-attacker/agent",
},
],
},
} as ClawdbotConfig;
mockMaybeCreateDynamicAgent.mockResolvedValueOnce({
created: false,
updatedCfg: refreshedCfg,
});
const event: FeishuMessageEvent = {
sender: {
@@ -1250,12 +1284,177 @@ describe("handleFeishuMessage command authorization", () => {
await dispatchMessage({ cfg, event });
const dynamicAgentRequest = mockCallArg<{
configWritesAllowed?: boolean;
accountId?: string;
senderOpenId?: string;
}>(mockMaybeCreateDynamicAgent, 0, 0);
expect(dynamicAgentRequest.senderOpenId).toBe("ou-attacker");
expect(dynamicAgentRequest.configWritesAllowed).toBe(false);
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(dynamicAgentRequest.accountId).toBe("default");
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({ cfg: refreshedCfg }),
);
expect(mockDispatchReplyFromConfig).toHaveBeenCalledWith(
expect.objectContaining({ cfg: refreshedCfg }),
);
});
it("drops a DM denied by refreshed dynamic-agent policy", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg = {
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["*"],
dynamicAgentCreation: { enabled: true },
},
},
} as ClawdbotConfig;
const refreshedCfg = {
channels: {
feishu: {
dmPolicy: "allowlist",
allowFrom: ["ou-admin"],
dynamicAgentCreation: { enabled: true },
},
},
} as ClawdbotConfig;
await dispatchMessage({
cfg,
currentCfg: refreshedCfg,
event: {
sender: { sender_id: { open_id: "ou-attacker" } },
message: {
message_id: "msg-refreshed-policy-deny",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(mockMaybeCreateDynamicAgent).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("reauthorizes current policy before dispatching an existing bound route", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockResolveAgentRoute.mockReturnValue({
...buildDefaultResolveRoute(),
matchedBy: "binding.peer",
});
const cfg = {
channels: { feishu: { dmPolicy: "open", allowFrom: ["*"] } },
} as ClawdbotConfig;
const currentCfg = {
channels: { feishu: { dmPolicy: "allowlist", allowFrom: ["ou-admin"] } },
} as ClawdbotConfig;
await dispatchMessage({
cfg,
currentCfg,
event: {
sender: { sender_id: { open_id: "ou-attacker" } },
message: {
message_id: "msg-bound-refreshed-policy-deny",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("issues a pairing challenge before dynamic creation when current policy requires it", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
const cfg = {
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["*"],
dynamicAgentCreation: { enabled: true },
},
},
} as ClawdbotConfig;
const currentCfg = {
channels: {
feishu: {
dmPolicy: "pairing",
allowFrom: [],
dynamicAgentCreation: { enabled: true },
},
},
} as ClawdbotConfig;
await dispatchMessage({
cfg,
currentCfg,
event: {
sender: { sender_id: { open_id: "ou-attacker" } },
message: {
message_id: "msg-refreshed-policy-pairing",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(mockMaybeCreateDynamicAgent).not.toHaveBeenCalled();
expect(mockUpsertPairingRequest).toHaveBeenCalledTimes(1);
expect(mockSendMessageFeishu).toHaveBeenCalledTimes(1);
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("recomputes command authorization against refreshed dynamic-agent config", async () => {
const cfg = {
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["*"],
dynamicAgentCreation: { enabled: true },
},
},
} as ClawdbotConfig;
const refreshedCfg = {
...cfg,
commands: { useAccessGroups: true },
} as ClawdbotConfig;
mockShouldComputeCommandAuthorized.mockImplementation((_body, candidateCfg) => {
return candidateCfg === refreshedCfg;
});
mockMaybeCreateDynamicAgent.mockResolvedValueOnce({
created: false,
updatedCfg: refreshedCfg,
});
await dispatchMessage({
cfg,
event: {
sender: { sender_id: { open_id: "ou-attacker" } },
message: {
message_id: "msg-refreshed-command-auth",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
},
});
expect(mockShouldComputeCommandAuthorized).toHaveBeenCalledWith("/status", refreshedCfg);
const context = mockCallArg<{ CommandAuthorized?: boolean }>(mockFinalizeInboundContext, 0, 0);
expect(context.CommandAuthorized).toBe(true);
});
it("blocks open DMs when a restrictive allowlist does not match", async () => {

View File

@@ -1,5 +1,4 @@
// Feishu plugin module implements bot behavior.
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
import {
buildChannelInboundEventContext,
toInboundMediaFacts,
@@ -74,7 +73,6 @@ import {
type FeishuMessageInfo,
type ResolvedFeishuAccount,
} from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
export { toMessageResourceType } from "./bot-content.js";
@@ -748,22 +746,44 @@ export async function handleFeishuMessage(params: {
commandProbeBody,
cfg,
);
const dmIngress = isDirect
? await resolveFeishuDmIngressAccess({
cfg,
accountId: account.accountId,
dmPolicy,
allowFrom: configAllowFrom,
readAllowFromStore: pairing.readAllowFromStore,
senderOpenId: ctx.senderOpenId,
senderUserId,
conversationId: ctx.senderOpenId,
mayPair: true,
...(shouldComputeCommandAuthorized ? { command: { hasControlCommand: true } } : {}),
})
: null;
if (isDirect && dmIngress?.ingress.admission !== "dispatch") {
if (dmIngress?.ingress.admission === "pairing-required") {
const resolveDirectAuthorization = async (
candidateCfg: ClawdbotConfig,
mayPair: boolean,
shouldComputeCommand = core.channel.commands.shouldComputeCommandAuthorized(
commandProbeBody,
candidateCfg,
),
) => {
const candidateAccount = resolveFeishuRuntimeAccount({
cfg: candidateCfg,
accountId: account.accountId,
});
const candidateDmPolicy = candidateAccount.config.dmPolicy ?? "pairing";
const candidateConfigAllowFrom = candidateAccount.config.allowFrom ?? [];
const ingress = await resolveFeishuDmIngressAccess({
cfg: candidateCfg,
accountId: candidateAccount.accountId,
dmPolicy: candidateDmPolicy,
allowFrom: candidateConfigAllowFrom,
readAllowFromStore: pairing.readAllowFromStore,
senderOpenId: ctx.senderOpenId,
senderUserId,
conversationId: ctx.senderOpenId,
mayPair,
...(shouldComputeCommand ? { command: { hasControlCommand: true } } : {}),
});
return {
cfg: candidateCfg,
dmPolicy: candidateDmPolicy,
configAllowFrom: candidateConfigAllowFrom,
ingress,
shouldComputeCommandAuthorized: shouldComputeCommand,
};
};
const rejectDirectAuthorization = async (
authorization: Awaited<ReturnType<typeof resolveDirectAuthorization>>,
) => {
if (authorization.ingress.ingress.admission === "pairing-required") {
await pairing.issueChallenge({
senderId: ctx.senderOpenId,
senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
@@ -773,7 +793,7 @@ export async function handleFeishuMessage(params: {
},
sendPairingReply: async (text) => {
await sendMessageFeishu({
cfg,
cfg: authorization.cfg,
to: `chat:${ctx.chatId}`,
text,
accountId: account.accountId,
@@ -787,15 +807,43 @@ export async function handleFeishuMessage(params: {
});
} else {
log(
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} ` +
`(dmPolicy=${authorization.dmPolicy})`,
);
}
};
const directAuthorization = isDirect
? await resolveDirectAuthorization(cfg, true, shouldComputeCommandAuthorized)
: null;
const dmIngress = directAuthorization?.ingress ?? null;
if (isDirect && dmIngress?.ingress.admission !== "dispatch") {
if (directAuthorization) {
await rejectDirectAuthorization(directAuthorization);
}
return;
}
const commandAllowFrom = isGroup
? (groupConfig?.allowFrom ?? configAllowFrom)
: (dmIngress?.senderAccess.effectiveAllowFrom ?? configAllowFrom);
let effectiveDmPolicy = directAuthorization?.dmPolicy ?? dmPolicy;
let effectiveConfigAllowFrom = directAuthorization?.configAllowFrom ?? configAllowFrom;
let effectiveDmIngress = dmIngress;
let effectiveShouldComputeCommandAuthorized =
directAuthorization?.shouldComputeCommandAuthorized ?? shouldComputeCommandAuthorized;
let effectiveCfg = cfg;
if (isDirect) {
const currentCfg = getFeishuRuntime().config.current() as ClawdbotConfig;
if (currentCfg !== effectiveCfg) {
const currentAuthorization = await resolveDirectAuthorization(currentCfg, true);
if (currentAuthorization.ingress.ingress.admission !== "dispatch") {
await rejectDirectAuthorization(currentAuthorization);
return;
}
effectiveCfg = currentCfg;
effectiveDmPolicy = currentAuthorization.dmPolicy;
effectiveConfigAllowFrom = currentAuthorization.configAllowFrom;
effectiveDmIngress = currentAuthorization.ingress;
effectiveShouldComputeCommandAuthorized =
currentAuthorization.shouldComputeCommandAuthorized;
}
}
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
// Using a group-scoped From causes the agent to treat different users as the same person.
@@ -823,7 +871,7 @@ export async function handleFeishuMessage(params: {
}
let route = core.channel.routing.resolveAgentRoute({
cfg,
cfg: effectiveCfg,
channel: "feishu",
accountId: account.accountId,
peer: {
@@ -833,34 +881,43 @@ export async function handleFeishuMessage(params: {
parentPeer,
});
// Dynamic agent creation for DM users
// When enabled, creates a unique agent instance with its own workspace for each DM user.
let effectiveCfg = cfg;
// Refresh a binding written after this request snapshot, or create the DM's
// dynamic agent when the current account policy enables it.
if (!isGroup && route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const runtimeLocal = getFeishuRuntime();
const result = await maybeCreateDynamicAgent({
cfg,
runtime: runtimeLocal,
senderOpenId: ctx.senderOpenId,
dynamicCfg,
configWritesAllowed: resolveChannelConfigWrites({
cfg,
channelId: "feishu",
accountId: account.accountId,
}),
log: (msg) => log(msg),
const runtimeLocal = getFeishuRuntime();
const result = await maybeCreateDynamicAgent({
cfg: effectiveCfg,
runtime: runtimeLocal,
accountId: account.accountId,
senderOpenId: ctx.senderOpenId,
canCreateForConfig: async (candidateCfg) => {
const authorization = await resolveDirectAuthorization(candidateCfg, false);
return authorization.ingress.ingress.admission === "dispatch";
},
log: (msg) => log(msg),
});
if (result.created || result.updatedCfg !== effectiveCfg) {
const refreshedAuthorization = await resolveDirectAuthorization(result.updatedCfg, false);
if (refreshedAuthorization.ingress.ingress.admission !== "dispatch") {
log(
`feishu[${account.accountId}]: current policy rejected stale DM from ${ctx.senderOpenId} ` +
`before adopting refreshed dynamic route (dmPolicy=${refreshedAuthorization.dmPolicy})`,
);
return;
}
effectiveCfg = result.updatedCfg;
effectiveDmPolicy = refreshedAuthorization.dmPolicy;
effectiveConfigAllowFrom = refreshedAuthorization.configAllowFrom;
effectiveDmIngress = refreshedAuthorization.ingress;
effectiveShouldComputeCommandAuthorized =
refreshedAuthorization.shouldComputeCommandAuthorized;
route = core.channel.routing.resolveAgentRoute({
cfg: result.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: { kind: "direct", id: ctx.senderOpenId },
});
if (result.created) {
effectiveCfg = result.updatedCfg;
// Re-resolve route with updated config
route = core.channel.routing.resolveAgentRoute({
cfg: result.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: { kind: "direct", id: ctx.senderOpenId },
});
log(
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
);
@@ -868,6 +925,10 @@ export async function handleFeishuMessage(params: {
}
}
const commandAllowFrom = isGroup
? (groupConfig?.allowFrom ?? effectiveConfigAllowFrom)
: (effectiveDmIngress?.senderAccess.effectiveAllowFrom ?? effectiveConfigAllowFrom);
const currentConversationId = peerId;
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
let configuredBinding = null;
@@ -1006,15 +1067,18 @@ export async function handleFeishuMessage(params: {
: audioTranscript;
const shouldComputeEffectiveCommandAuthorized =
audioTranscript === undefined
? shouldComputeCommandAuthorized
: core.channel.commands.shouldComputeCommandAuthorized(effectiveCommandProbeBody, cfg);
? effectiveShouldComputeCommandAuthorized
: core.channel.commands.shouldComputeCommandAuthorized(
effectiveCommandProbeBody,
effectiveCfg,
);
const commandAuthorized = shouldComputeEffectiveCommandAuthorized
? isDirect && audioTranscript === undefined && dmIngress
? dmIngress.commandAccess.authorized
? isDirect && audioTranscript === undefined && effectiveDmIngress
? effectiveDmIngress.commandAccess.authorized
: isGroup
? (
await resolveFeishuGroupSenderActivationIngressAccess({
cfg,
cfg: effectiveCfg,
accountId: account.accountId,
chatId: ctx.chatId,
allowFrom: commandAllowFrom,
@@ -1027,10 +1091,10 @@ export async function handleFeishuMessage(params: {
).commandAccess.authorized
: (
await resolveFeishuDmIngressAccess({
cfg,
cfg: effectiveCfg,
accountId: account.accountId,
dmPolicy,
allowFrom: configAllowFrom,
dmPolicy: effectiveDmPolicy,
allowFrom: effectiveConfigAllowFrom,
readAllowFromStore: pairing.readAllowFromStore,
senderOpenId: ctx.senderOpenId,
senderUserId,
@@ -1413,8 +1477,8 @@ export async function handleFeishuMessage(params: {
: undefined;
const pinnedMainDmOwner = !isGroup
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom: configAllowFrom,
dmScope: effectiveCfg.session?.dmScope,
allowFrom: effectiveConfigAllowFrom,
normalizeEntry: normalizeFeishuAllowEntry,
})
: null;
@@ -1690,19 +1754,19 @@ export async function handleFeishuMessage(params: {
ctx.mentionedBot,
);
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
const identity = resolveAgentOutboundIdentity(effectiveCfg, route.agentId);
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {
agentId: route.agentId,
});
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
cfg,
cfg: effectiveCfg,
agentId: route.agentId,
storePath,
sessionKey: route.sessionKey,
});
const { dispatcher, replyOptions, markDispatchIdle, ensureNoVisibleReplyFallback } =
createFeishuReplyDispatcher({
cfg,
cfg: effectiveCfg,
agentId: route.agentId,
runtime: runtime as RuntimeEnv,
chatId: ctx.chatId,
@@ -1771,7 +1835,7 @@ export async function handleFeishuMessage(params: {
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
cfg: effectiveCfg,
dispatcher,
replyOptions,
}),

View File

@@ -53,6 +53,8 @@ function buildConfig(overrides?: Partial<ClawdbotConfig>): ClawdbotConfig {
} as ClawdbotConfig;
}
let currentRuntimeConfig = buildConfig();
function buildResolvedRoute(matchedBy: "binding.channel" | "default" = "binding.channel") {
return {
agentId: "main",
@@ -77,6 +79,7 @@ function mockCallArg(mockFn: ReturnType<typeof vi.fn>, label: string, callIndex
}
function createTestRuntime(overrides?: {
currentCfg?: ClawdbotConfig;
readAllowFromStore?: () => Promise<unknown[]>;
upsertPairingRequest?: () => Promise<{ code: string; created: boolean }>;
resolveAgentRoute?: () => ReturnType<typeof buildResolvedRoute>;
@@ -129,6 +132,9 @@ function createTestRuntime(overrides?: {
});
return {
config: {
current: vi.fn(() => overrides?.currentCfg ?? currentRuntimeConfig),
},
channel: {
routing: {
buildAgentSessionKey: vi.fn(
@@ -200,7 +206,11 @@ describe("handleFeishuCommentEvent", () => {
beforeEach(() => {
vi.clearAllMocks();
maybeCreateDynamicAgentMock.mockResolvedValue({ created: false });
currentRuntimeConfig = buildConfig();
maybeCreateDynamicAgentMock.mockImplementation(async ({ cfg }) => ({
created: false,
updatedCfg: cfg,
}));
resolveDriveCommentEventTurnMock.mockResolvedValue({
eventId: "evt_1",
messageId: "drive-comment:evt_1",
@@ -323,26 +333,28 @@ describe("handleFeishuCommentEvent", () => {
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
});
it("passes disabled config-write policy to dynamic agent creation", async () => {
it("passes the resolved account to dynamic agent resolution", async () => {
const cfg = buildConfig({
channels: {
feishu: {
enabled: true,
dmPolicy: "open",
allowFrom: ["*"],
configWrites: false,
dynamicAgentCreation: {
enabled: true,
},
},
},
});
const runtime = createTestRuntime({
currentCfg: cfg,
resolveAgentRoute: () => buildResolvedRoute("default"),
});
setFeishuRuntime(runtime);
await handleFeishuCommentEvent({
cfg: buildConfig({
channels: {
feishu: {
enabled: true,
dmPolicy: "open",
allowFrom: ["*"],
configWrites: false,
dynamicAgentCreation: {
enabled: true,
},
},
},
}),
cfg,
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
@@ -354,16 +366,88 @@ describe("handleFeishuCommentEvent", () => {
expect(maybeCreateDynamicAgentMock).toHaveBeenCalledTimes(1);
const dynamicAgentArgs = mockCallArg(maybeCreateDynamicAgentMock, "maybeCreateDynamicAgent") as
| { configWritesAllowed?: boolean; senderOpenId?: string }
| { accountId?: string; senderOpenId?: string }
| undefined;
expect(dynamicAgentArgs?.senderOpenId).toBe("ou_sender");
expect(dynamicAgentArgs?.configWritesAllowed).toBe(false);
expect(dynamicAgentArgs?.accountId).toBe("default");
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("drops a comment denied by refreshed dynamic-agent policy", async () => {
const refreshedCfg = buildConfig({
channels: {
feishu: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["ou_admin"],
},
},
});
const runtime = createTestRuntime({
currentCfg: refreshedCfg,
resolveAgentRoute: () => buildResolvedRoute("default"),
});
setFeishuRuntime(runtime);
const cfg = buildConfig();
await handleFeishuCommentEvent({
cfg,
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("issues a pairing challenge before dynamic comment-agent creation", async () => {
const currentCfg = buildConfig({
channels: {
feishu: {
enabled: true,
dmPolicy: "pairing",
allowFrom: [],
dynamicAgentCreation: { enabled: true },
},
},
});
const runtime = createTestRuntime({
currentCfg,
resolveAgentRoute: () => buildResolvedRoute("default"),
});
setFeishuRuntime(runtime);
await handleFeishuCommentEvent({
cfg: buildConfig(),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(maybeCreateDynamicAgentMock).not.toHaveBeenCalled();
expect(deliverCommentThreadTextMock).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
const runtime = createTestRuntime();
setFeishuRuntime(runtime);

View File

@@ -1,5 +1,4 @@
// Feishu plugin module implements comment handler behavior.
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
@@ -19,7 +18,6 @@ import {
} from "./monitor.comment.js";
import { resolveFeishuDmIngressAccess } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import type { DynamicAgentCreationConfig } from "./types.js";
type HandleFeishuCommentEventParams = {
cfg: ClawdbotConfig;
@@ -55,7 +53,6 @@ export async function handleFeishuCommentEvent(
params: HandleFeishuCommentEventParams,
): Promise<void> {
const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
const core = getFeishuRuntime();
const log = params.runtime?.log ?? console.log;
const error = params.runtime?.error ?? console.error;
@@ -81,27 +78,35 @@ export async function handleFeishuCommentEvent(
fileToken: turn.fileToken,
commentId: turn.commentId,
});
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const pairing = createChannelPairingController({
core,
channel: "feishu",
accountId: account.accountId,
});
const dmIngress = await resolveFeishuDmIngressAccess({
cfg: params.cfg,
accountId: account.accountId,
dmPolicy,
allowFrom: configAllowFrom,
readAllowFromStore: pairing.readAllowFromStore,
senderOpenId: turn.senderId,
senderUserId: turn.senderUserId,
conversationId: turn.senderId,
mayPair: true,
});
if (dmIngress.ingress.admission !== "dispatch") {
if (dmIngress.ingress.admission === "pairing-required") {
const client = createFeishuClient(account);
const resolveCommentAuthorization = async (candidateCfg: ClawdbotConfig, mayPair: boolean) => {
const candidateAccount = resolveFeishuRuntimeAccount({
cfg: candidateCfg,
accountId: account.accountId,
});
const candidateDmPolicy = candidateAccount.config.dmPolicy ?? "pairing";
const ingress = await resolveFeishuDmIngressAccess({
cfg: candidateCfg,
accountId: candidateAccount.accountId,
dmPolicy: candidateDmPolicy,
allowFrom: candidateAccount.config.allowFrom ?? [],
readAllowFromStore: pairing.readAllowFromStore,
senderOpenId: turn.senderId,
senderUserId: turn.senderUserId,
conversationId: turn.senderId,
mayPair,
});
return { account: candidateAccount, cfg: candidateCfg, dmPolicy: candidateDmPolicy, ingress };
};
const rejectCommentAuthorization = async (
authorization: Awaited<ReturnType<typeof resolveCommentAuthorization>>,
) => {
if (authorization.ingress.ingress.admission === "pairing-required") {
const client = createFeishuClient(authorization.account);
await pairing.issueChallenge({
senderId: turn.senderId,
senderIdLine: `Your Feishu user id: ${turn.senderId}`,
@@ -129,15 +134,28 @@ export async function handleFeishuCommentEvent(
} else {
log(
`feishu[${account.accountId}]: blocked unauthorized comment sender ${turn.senderId} ` +
`(dmPolicy=${dmPolicy}, comment=${turn.commentId})`,
`(dmPolicy=${authorization.dmPolicy}, comment=${turn.commentId})`,
);
}
};
const commentAuthorization = await resolveCommentAuthorization(params.cfg, true);
if (commentAuthorization.ingress.ingress.admission !== "dispatch") {
await rejectCommentAuthorization(commentAuthorization);
return;
}
let effectiveCfg = params.cfg;
const currentCfg = core.config.current() as ClawdbotConfig;
if (currentCfg !== effectiveCfg) {
const currentAuthorization = await resolveCommentAuthorization(currentCfg, true);
if (currentAuthorization.ingress.ingress.admission !== "dispatch") {
await rejectCommentAuthorization(currentAuthorization);
return;
}
effectiveCfg = currentCfg;
}
let route = core.channel.routing.resolveAgentRoute({
cfg: params.cfg,
cfg: effectiveCfg,
channel: "feishu",
accountId: account.accountId,
peer: {
@@ -146,31 +164,40 @@ export async function handleFeishuCommentEvent(
},
});
if (route.matchedBy === "default") {
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
if (dynamicCfg?.enabled) {
const dynamicResult = await maybeCreateDynamicAgent({
cfg: params.cfg,
runtime: core,
senderOpenId: turn.senderId,
dynamicCfg,
configWritesAllowed: resolveChannelConfigWrites({
cfg: params.cfg,
channelId: "feishu",
accountId: account.accountId,
}),
log: (message) => log(message),
const dynamicResult = await maybeCreateDynamicAgent({
cfg: effectiveCfg,
runtime: core,
accountId: account.accountId,
senderOpenId: turn.senderId,
canCreateForConfig: async (candidateCfg) => {
const authorization = await resolveCommentAuthorization(candidateCfg, false);
return authorization.ingress.ingress.admission === "dispatch";
},
log: (message) => log(message),
});
if (dynamicResult.created || dynamicResult.updatedCfg !== effectiveCfg) {
const refreshedAuthorization = await resolveCommentAuthorization(
dynamicResult.updatedCfg,
false,
);
if (refreshedAuthorization.ingress.ingress.admission !== "dispatch") {
log(
`feishu[${account.accountId}]: current policy rejected stale comment sender ${turn.senderId} ` +
`before adopting refreshed dynamic route (dmPolicy=${refreshedAuthorization.dmPolicy}, comment=${turn.commentId})`,
);
return;
}
effectiveCfg = dynamicResult.updatedCfg;
route = core.channel.routing.resolveAgentRoute({
cfg: dynamicResult.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: "direct",
id: turn.senderId,
},
});
if (dynamicResult.created) {
effectiveCfg = dynamicResult.updatedCfg;
route = core.channel.routing.resolveAgentRoute({
cfg: dynamicResult.updatedCfg,
channel: "feishu",
accountId: account.accountId,
peer: {
kind: "direct",
id: turn.senderId,
},
});
log(
`feishu[${account.accountId}]: dynamic agent created for comment flow, route=${route.sessionKey}`,
);

View File

@@ -16,15 +16,33 @@ afterEach(async () => {
await fs.promises.rm(tempRoot, { recursive: true, force: true });
});
function createRuntime() {
const replaceConfigFile = vi.fn(async () => {});
function createRuntime(
currentCfg?: OpenClawConfig,
persistedCfg?: OpenClawConfig,
mutationCfg?: OpenClawConfig,
) {
let runtimeCfg = structuredClone(currentCfg ?? ({} as OpenClawConfig));
const commitConfig = vi.fn();
const mutateConfigFile = vi.fn(
async (params: {
mutate: (draft: OpenClawConfig, context: { snapshot: never; previousHash: null }) => unknown;
}) => {
const draft = structuredClone(mutationCfg ?? runtimeCfg);
const result = await params.mutate(draft, { snapshot: {} as never, previousHash: null });
runtimeCfg = draft;
commitConfig();
return { nextConfig: persistedCfg ?? runtimeCfg, result };
},
);
return {
runtime: {
config: {
replaceConfigFile,
mutateConfigFile,
current: vi.fn(() => runtimeCfg),
},
} as unknown as PluginRuntime,
replaceConfigFile,
commitConfig,
mutateConfigFile,
};
}
@@ -50,37 +68,396 @@ async function pathExists(target: string): Promise<boolean> {
describe("maybeCreateDynamicAgent", () => {
it("does not persist dynamic agents when config writes are disabled", async () => {
const { runtime, replaceConfigFile } = createRuntime();
const dynamicCfg = createDynamicConfig();
const cfg = {
channels: {
feishu: {
configWrites: false,
dynamicAgentCreation: createDynamicConfig(),
},
},
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const { runtime, mutateConfigFile } = createRuntime(cfg);
const result = await maybeCreateDynamicAgent({
cfg: {
channels: { feishu: { configWrites: false } },
agents: { list: [] },
bindings: [],
} as OpenClawConfig,
cfg,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
dynamicCfg,
configWritesAllowed: false,
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(result).toEqual({
created: false,
updatedCfg: {
channels: { feishu: { configWrites: false } },
agents: { list: [] },
bindings: [],
},
});
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(result).toEqual({ created: false, updatedCfg: cfg });
expect(mutateConfigFile).not.toHaveBeenCalled();
expect(await pathExists(path.join(tempRoot, "workspace-feishu-ou_sender"))).toBe(false);
expect(await pathExists(path.join(tempRoot, "agent-feishu-ou_sender"))).toBe(false);
});
it("persists a sender agent and direct binding when config writes are allowed", async () => {
const { runtime, replaceConfigFile } = createRuntime();
const cfg = {
channels: { feishu: { dynamicAgentCreation: createDynamicConfig() } },
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const { runtime, mutateConfigFile } = createRuntime(cfg);
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(result.created).toBe(true);
expect(result.agentId).toBe("feishu-ou_sender");
expect(mutateConfigFile).toHaveBeenCalledTimes(1);
expect(mutateConfigFile).toHaveBeenCalledWith({
base: "runtime",
afterWrite: { mode: "auto" },
mutate: expect.any(Function),
});
expect(result.updatedCfg.agents?.list).toEqual([
{
id: "feishu-ou_sender",
workspace: path.join(tempRoot, "workspace-feishu-ou_sender"),
agentDir: path.join(tempRoot, "agent-feishu-ou_sender"),
},
]);
expect(result.updatedCfg.bindings).toEqual([
{
agentId: "feishu-ou_sender",
match: {
channel: "feishu",
accountId: "default",
peer: { kind: "direct", id: "ou_sender" },
},
},
]);
expect(await pathExists(path.join(tempRoot, "workspace-feishu-ou_sender"))).toBe(true);
expect(await pathExists(path.join(tempRoot, "agent-feishu-ou_sender"))).toBe(true);
});
it("does not create persistent state when current ingress denies the sender", async () => {
const cfg = {
channels: { feishu: { dynamicAgentCreation: createDynamicConfig() } },
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const { runtime, mutateConfigFile } = createRuntime(cfg);
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
canCreateForConfig: async () => false,
log: vi.fn(),
});
expect(result).toEqual({ created: false, updatedCfg: cfg });
expect(mutateConfigFile).not.toHaveBeenCalled();
expect(await pathExists(path.join(tempRoot, "workspace-feishu-ou_sender"))).toBe(false);
expect(await pathExists(path.join(tempRoot, "agent-feishu-ou_sender"))).toBe(false);
});
it("rechecks current ingress inside the config mutation lock", async () => {
const cfg = {
channels: { feishu: { dynamicAgentCreation: createDynamicConfig() } },
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const { runtime, commitConfig, mutateConfigFile } = createRuntime(cfg);
const canCreateForConfig = vi
.fn<(cfg: OpenClawConfig) => Promise<boolean>>()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
canCreateForConfig,
log: vi.fn(),
});
expect(result.created).toBe(false);
expect(canCreateForConfig).toHaveBeenCalledTimes(2);
expect(mutateConfigFile).toHaveBeenCalledTimes(1);
expect(commitConfig).not.toHaveBeenCalled();
expect(result.updatedCfg.agents?.list).toEqual([]);
expect(result.updatedCfg.bindings).toEqual([]);
expect(await pathExists(path.join(tempRoot, "workspace-feishu-ou_sender"))).toBe(false);
expect(await pathExists(path.join(tempRoot, "agent-feishu-ou_sender"))).toBe(false);
});
it("preserves a non-peer route added before the config mutation lock", async () => {
const cfg = {
channels: { feishu: { dynamicAgentCreation: createDynamicConfig() } },
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const mutationCfg = {
...cfg,
bindings: [
{
agentId: "main",
match: { channel: "feishu", accountId: "default" },
},
],
} as OpenClawConfig;
const { runtime, commitConfig, mutateConfigFile } = createRuntime(cfg, undefined, mutationCfg);
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(result.created).toBe(false);
expect(result.updatedCfg).toEqual(mutationCfg);
expect(mutateConfigFile).toHaveBeenCalledTimes(1);
expect(commitConfig).not.toHaveBeenCalled();
});
it("scopes bindings to the normalized account id", async () => {
const cfg = {
channels: { feishu: { dynamicAgentCreation: createDynamicConfig() } },
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const { runtime } = createRuntime(cfg);
const result = await maybeCreateDynamicAgent({
cfg,
runtime,
accountId: "Ops Team",
senderOpenId: "ou_sender",
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(result.created).toBe(true);
expect(result.agentId).toMatch(/^feishu-ops-team-[a-f0-9]{32}$/);
expect(result.updatedCfg.bindings).toEqual([
{
agentId: result.agentId,
match: {
channel: "feishu",
accountId: "ops-team",
peer: { kind: "direct", id: "ou_sender" },
},
},
]);
});
it("keeps named-account dynamic agent ids bounded and sender-unique", async () => {
const accountId = "a".repeat(64);
const cfg = {
channels: { feishu: { dynamicAgentCreation: createDynamicConfig() } },
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const { runtime } = createRuntime(cfg);
const first = await maybeCreateDynamicAgent({
cfg,
runtime,
accountId,
senderOpenId: "ou_sender_one_with_a_shared_long_prefix",
canCreateForConfig: async () => true,
log: vi.fn(),
});
const second = await maybeCreateDynamicAgent({
cfg: first.updatedCfg,
runtime,
accountId,
senderOpenId: "ou_sender_two_with_a_shared_long_prefix",
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(first.agentId).toHaveLength(52);
expect(second.agentId).toHaveLength(52);
expect(first.agentId).not.toBe(second.agentId);
expect(second.updatedCfg.agents?.list?.map((agent) => agent.id)).toEqual([
first.agentId,
second.agentId,
]);
});
it("uses the current maxAgents limit instead of stale request policy", async () => {
const cfg = {
channels: {
feishu: {
dynamicAgentCreation: {
...createDynamicConfig(),
maxAgents: 1,
},
},
},
agents: {
list: [
{
id: "feishu-ou_existing",
workspace: path.join(tempRoot, "existing-workspace"),
agentDir: path.join(tempRoot, "existing-agent"),
},
],
},
bindings: [],
} as OpenClawConfig;
const { runtime, mutateConfigFile } = createRuntime(cfg);
const result = await maybeCreateDynamicAgent({
cfg: {
channels: {
feishu: {
dynamicAgentCreation: {
...createDynamicConfig(),
maxAgents: 2,
},
},
},
agents: cfg.agents,
bindings: [],
} as OpenClawConfig,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(result.created).toBe(false);
expect(mutateConfigFile).not.toHaveBeenCalled();
});
it("preserves concurrent runtime config when creating from a stale request snapshot", async () => {
const currentCfg = {
channels: { feishu: { dynamicAgentCreation: createDynamicConfig() } },
agents: {
list: [
{
id: "feishu-ou_existing",
workspace: path.join(tempRoot, "existing-workspace"),
agentDir: path.join(tempRoot, "existing-agent"),
},
],
},
bindings: [
{
agentId: "feishu-ou_existing",
match: {
channel: "feishu",
peer: { kind: "direct", id: "ou_existing" },
},
},
],
} as OpenClawConfig;
const { runtime, mutateConfigFile } = createRuntime(currentCfg);
const result = await maybeCreateDynamicAgent({
cfg: { agents: { list: [] }, bindings: [] } as OpenClawConfig,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(mutateConfigFile).toHaveBeenCalledWith({
base: "runtime",
afterWrite: { mode: "auto" },
mutate: expect.any(Function),
});
expect(result.updatedCfg.agents?.list).toEqual([
...currentCfg.agents!.list!,
{
id: "feishu-ou_sender",
workspace: path.join(tempRoot, "workspace-feishu-ou_sender"),
agentDir: path.join(tempRoot, "agent-feishu-ou_sender"),
},
]);
expect(result.updatedCfg.bindings).toEqual([
...currentCfg.bindings!,
{
agentId: "feishu-ou_sender",
match: {
channel: "feishu",
accountId: "default",
peer: { kind: "direct", id: "ou_sender" },
},
},
]);
});
it("returns refreshed runtime config instead of the persisted source config", async () => {
const currentCfg = {
channels: {
feishu: {
appSecret: "resolved-secret",
dynamicAgentCreation: createDynamicConfig(),
},
},
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const persistedCfg = {
channels: {
feishu: {
appSecret: { source: "env", id: "FEISHU_APP_SECRET" },
dynamicAgentCreation: createDynamicConfig(),
},
},
agents: { list: [] },
bindings: [],
} as OpenClawConfig;
const { runtime } = createRuntime(currentCfg, persistedCfg);
const result = await maybeCreateDynamicAgent({
cfg: currentCfg,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(result.updatedCfg.channels?.feishu?.appSecret).toBe("resolved-secret");
expect(result.updatedCfg.bindings).toHaveLength(1);
});
it("returns runtime current binding even when config writes are disabled", async () => {
const currentCfg = {
channels: { feishu: { configWrites: false } },
agents: {
list: [
{
id: "feishu-ou_sender",
workspace: path.join(tempRoot, "existing-workspace"),
agentDir: path.join(tempRoot, "existing-agent"),
},
],
},
bindings: [
{
agentId: "feishu-ou_sender",
match: {
channel: "feishu",
peer: { kind: "direct", id: "ou_sender" },
},
},
],
} as OpenClawConfig;
const { runtime, mutateConfigFile } = createRuntime(currentCfg);
const result = await maybeCreateDynamicAgent({
cfg: {
@@ -88,69 +465,14 @@ describe("maybeCreateDynamicAgent", () => {
bindings: [],
} as OpenClawConfig,
runtime,
accountId: "default",
senderOpenId: "ou_sender",
dynamicCfg: createDynamicConfig(),
configWritesAllowed: true,
log: vi.fn(),
});
expect(result.created).toBe(true);
expect(result.agentId).toBe("feishu-ou_sender");
expect(replaceConfigFile).toHaveBeenCalledTimes(1);
expect(replaceConfigFile).toHaveBeenCalledWith({
nextConfig: {
agents: {
list: [
{
id: "feishu-ou_sender",
workspace: path.join(tempRoot, "workspace-feishu-ou_sender"),
agentDir: path.join(tempRoot, "agent-feishu-ou_sender"),
},
],
},
bindings: [
{
agentId: "feishu-ou_sender",
match: {
channel: "feishu",
peer: { kind: "direct", id: "ou_sender" },
},
},
],
},
afterWrite: { mode: "auto" },
});
expect(await pathExists(path.join(tempRoot, "workspace-feishu-ou_sender"))).toBe(true);
expect(await pathExists(path.join(tempRoot, "agent-feishu-ou_sender"))).toBe(true);
});
it("keeps the maxAgents limit before adding a missing binding", async () => {
const { runtime, replaceConfigFile } = createRuntime();
const result = await maybeCreateDynamicAgent({
cfg: {
agents: {
list: [
{
id: "feishu-ou_sender",
workspace: path.join(tempRoot, "existing-workspace"),
agentDir: path.join(tempRoot, "existing-agent"),
},
],
},
bindings: [],
} as OpenClawConfig,
runtime,
senderOpenId: "ou_sender",
dynamicCfg: {
...createDynamicConfig(),
maxAgents: 1,
},
configWritesAllowed: true,
canCreateForConfig: async () => true,
log: vi.fn(),
});
expect(result.created).toBe(false);
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(result.updatedCfg).toStrictEqual(currentCfg);
expect(mutateConfigFile).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,12 @@
// Feishu plugin module implements dynamic agent behavior.
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
import { normalizeAccountId, resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import type { DynamicAgentCreationConfig } from "./types.js";
type MaybeCreateDynamicAgentResult = {
@@ -11,126 +15,197 @@ type MaybeCreateDynamicAgentResult = {
agentId?: string;
};
type DynamicAgentMutationResult = {
created: boolean;
agentId?: string;
};
class DynamicAgentMutationSkipped extends Error {
constructor(readonly cfg: OpenClawConfig) {
super("dynamic agent mutation skipped");
}
}
function hasDefaultDirectRoute(
cfg: OpenClawConfig,
accountId: string,
senderOpenId: string,
): boolean {
return (
resolveAgentRoute({
cfg,
channel: "feishu",
accountId,
peer: { kind: "direct", id: senderOpenId },
}).matchedBy === "default"
);
}
function resolveDynamicAgentConfig(
cfg: OpenClawConfig,
accountId: string,
): DynamicAgentCreationConfig | undefined {
return resolveFeishuAccount({ cfg, accountId }).config.dynamicAgentCreation as
| DynamicAgentCreationConfig
| undefined;
}
function isAtDynamicAgentLimit(
cfg: OpenClawConfig,
dynamicCfg: DynamicAgentCreationConfig,
): boolean {
if (dynamicCfg.maxAgents === undefined) {
return false;
}
const feishuAgentCount = (cfg.agents?.list ?? []).filter((agent) =>
agent.id.startsWith("feishu-"),
).length;
return feishuAgentCount >= dynamicCfg.maxAgents;
}
function resolveDynamicAgentId(accountId: string, senderOpenId: string): string {
if (accountId === "default") {
return `feishu-${senderOpenId}`;
}
const identityDigest = createHash("sha256")
.update(accountId)
.update("\0")
.update(senderOpenId)
.digest("hex")
.slice(0, 32);
return `feishu-${accountId.slice(0, 12)}-${identityDigest}`;
}
/**
* Check if a dynamic agent should be created for a DM user and create it if needed.
* This creates a unique agent instance with its own workspace for each DM user.
* Refresh an existing DM binding or create its dynamic agent when current
* account policy permits config writes.
*/
export async function maybeCreateDynamicAgent(params: {
cfg: OpenClawConfig;
runtime: PluginRuntime;
accountId: string;
senderOpenId: string;
dynamicCfg: DynamicAgentCreationConfig;
configWritesAllowed: boolean;
canCreateForConfig: (cfg: OpenClawConfig) => Promise<boolean>;
log: (msg: string) => void;
}): Promise<MaybeCreateDynamicAgentResult> {
const { cfg, runtime, senderOpenId, dynamicCfg, configWritesAllowed, log } = params;
const { cfg, runtime, senderOpenId, canCreateForConfig, log } = params;
const accountId = normalizeAccountId(params.accountId);
if (!configWritesAllowed) {
if (!hasDefaultDirectRoute(cfg, accountId, senderOpenId)) {
return { created: false, updatedCfg: cfg };
}
const currentCfg = runtime.config.current() as OpenClawConfig;
if (!hasDefaultDirectRoute(currentCfg, accountId, senderOpenId)) {
return { created: false, updatedCfg: currentCfg };
}
const currentDynamicCfg = resolveDynamicAgentConfig(currentCfg, accountId);
if (!currentDynamicCfg?.enabled) {
return { created: false, updatedCfg: currentCfg };
}
if (!resolveChannelConfigWrites({ cfg: currentCfg, channelId: "feishu", accountId })) {
log(`feishu: config writes disabled, not creating agent for ${senderOpenId}`);
return { created: false, updatedCfg: cfg };
return { created: false, updatedCfg: currentCfg };
}
const agentId = resolveDynamicAgentId(accountId, senderOpenId);
const currentAgentExists = (currentCfg.agents?.list ?? []).some((agent) => agent.id === agentId);
// Legacy unscoped agents are indistinguishable from valid default-account state.
// Keep maxAgents as a hard cap instead of auto-rebinding or deleting ambiguous user data.
if (!currentAgentExists && isAtDynamicAgentLimit(currentCfg, currentDynamicCfg)) {
log(
`feishu: maxAgents limit (${currentDynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
);
return { created: false, updatedCfg: currentCfg };
}
if (!(await canCreateForConfig(currentCfg))) {
return { created: false, updatedCfg: currentCfg };
}
// Check if there's already a binding for this user
const existingBindings = cfg.bindings ?? [];
const hasBinding = existingBindings.some(
(b) =>
b.match?.channel === "feishu" &&
b.match?.peer?.kind === "direct" &&
b.match?.peer?.id === senderOpenId,
);
if (hasBinding) {
return { created: false, updatedCfg: cfg };
}
// Check maxAgents limit if configured
if (dynamicCfg.maxAgents !== undefined) {
const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
a.id.startsWith("feishu-"),
).length;
if (feishuAgentCount >= dynamicCfg.maxAgents) {
log(
`feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
);
return { created: false, updatedCfg: cfg };
}
}
// Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
const agentId = `feishu-${senderOpenId}`;
// Check if agent already exists (but binding was missing)
const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
if (existingAgent) {
// Agent exists but binding doesn't - just add the binding
log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
const updatedCfg: OpenClawConfig = {
...cfg,
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "direct", id: senderOpenId },
},
},
],
};
await runtime.config.replaceConfigFile({
nextConfig: updatedCfg,
// The config mutation lock owns the final duplicate/limit checks. This keeps
// simultaneous DM creations and policy updates from producing stale writes.
let skippedCfg: OpenClawConfig | undefined;
const committed = await runtime.config
.mutateConfigFile<DynamicAgentMutationResult>({
base: "runtime",
afterWrite: { mode: "auto" },
mutate: async (draft) => {
if (!hasDefaultDirectRoute(draft, accountId, senderOpenId)) {
throw new DynamicAgentMutationSkipped(draft);
}
const dynamicCfg = resolveDynamicAgentConfig(draft, accountId);
if (
!dynamicCfg?.enabled ||
!resolveChannelConfigWrites({ cfg: draft, channelId: "feishu", accountId })
) {
throw new DynamicAgentMutationSkipped(draft);
}
const agentExists = (draft.agents?.list ?? []).some((agent) => agent.id === agentId);
if (!agentExists && isAtDynamicAgentLimit(draft, dynamicCfg)) {
log(
`feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
);
throw new DynamicAgentMutationSkipped(draft);
}
if (!(await canCreateForConfig(draft))) {
throw new DynamicAgentMutationSkipped(draft);
}
if (!agentExists) {
const workspaceTemplate =
dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
const agentDirTemplate =
dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
const workspace = resolveUserPath(
workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
const agentDir = resolveUserPath(
agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
log(` workspace: ${workspace}`);
log(` agentDir: ${agentDir}`);
await fs.promises.mkdir(workspace, { recursive: true });
await fs.promises.mkdir(agentDir, { recursive: true });
draft.agents = {
...draft.agents,
list: [...(draft.agents?.list ?? []), { id: agentId, workspace, agentDir }],
};
} else {
log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
}
draft.bindings = [
...(draft.bindings ?? []),
{
agentId,
match: {
channel: "feishu",
accountId,
peer: { kind: "direct", id: senderOpenId },
},
},
];
return { created: true, agentId };
},
})
.catch((error: unknown) => {
if (error instanceof DynamicAgentMutationSkipped) {
skippedCfg = error.cfg;
return null;
}
throw error;
});
return { created: true, updatedCfg, agentId };
if (!committed) {
return { created: false, updatedCfg: skippedCfg ?? currentCfg };
}
// Resolve path templates with substitutions
const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
const workspace = resolveUserPath(
workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
const agentDir = resolveUserPath(
agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
);
log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
log(` workspace: ${workspace}`);
log(` agentDir: ${agentDir}`);
// Create directories
await fs.promises.mkdir(workspace, { recursive: true });
await fs.promises.mkdir(agentDir, { recursive: true });
// Update configuration with new agent and binding
const updatedCfg: OpenClawConfig = {
...cfg,
agents: {
...cfg.agents,
list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
},
bindings: [
...existingBindings,
{
agentId,
match: {
channel: "feishu",
peer: { kind: "direct", id: senderOpenId },
},
},
],
return {
created: committed.result?.created ?? false,
updatedCfg: runtime.config.current() as OpenClawConfig,
agentId: committed.result?.agentId,
};
// Write updated config using PluginRuntime API
await runtime.config.replaceConfigFile({
nextConfig: updatedCfg,
afterWrite: { mode: "auto" },
});
return { created: true, updatedCfg, agentId };
}
/**