Root cause
Other / insufficient public mechanism detail
feat(feishu): sync community contributions from clawdbot-feishu (#12662)
How AI contributed: Flawed AI-written codePublished Jul 2, 2026 in
openclaw/openclawAll 35 findings in this repository
Feishu dynamic-agent creation did not receive the channel/account configWrites decision, so a sender could reach dynamic-agent configuration behavior when config writes were disabled.
Root cause
feat(feishu): sync community contributions from clawdbot-feishu (#12662)
Fix
feishu: honor config write policy for dynamic agents [AI] (#78520)
Fix by Pavan Kumar Gondhi · no AI marker found
Case facts
@@ -0,0 +1,131 @@+import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";+import fs from "node:fs";+import os from "node:os";+import path from "node:path";+import type { DynamicAgentCreationConfig } from "./types.js";++export type MaybeCreateDynamicAgentResult = {+ created: boolean;+ updatedCfg: OpenClawConfig;+ agentId?: string;+};++/**+ * 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.+ */+export async function maybeCreateDynamicAgent(params: {+ cfg: OpenClawConfig;+ runtime: PluginRuntime;+ senderOpenId: string;+ dynamicCfg: DynamicAgentCreationConfig;+ log: (msg: string) => void;+}): Promise<MaybeCreateDynamicAgentResult> {+ const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;++ // 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 === "dm" &&+ 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: "dm", id: senderOpenId },+ },+ },+ ],+ };++ await runtime.config.writeConfigFile(updatedCfg);+ return { created: true, updatedCfg, agentId };+ }++ // 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),+ );+@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sd import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; import { resolveFeishuAccount,+ resolveFeishuCredentials, listFeishuAccountIds, resolveDefaultFeishuAccountId, } from "./accounts.js";@@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { probeFeishu } from "./probe.js"; import { sendMessageFeishu } from "./send.js";-import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";+import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; const meta: ChannelMeta = { id: "feishu",@@ -47,13 +48,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { }, }, capabilities: {- chatTypes: ["direct", "group"],+ chatTypes: ["direct", "channel"],+ polls: false,+ threads: true, media: true, reactions: true,- threads: false,- polls: false,- nativeCommands: true,- blockStreaming: true,+ edit: true,+ reply: true, }, agentPrompt: { messageToolHints: () => [@@ -92,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { items: { oneOf: [{ type: "string" }, { type: "number" }] }, }, requireMention: { type: "boolean" },+ topicSessionMode: { type: "string", enum: ["disabled", "enabled"] }, historyLimit: { type: "integer", minimum: 0 }, dmHistoryLimit: { type: "integer", minimum: 0 }, textChunkLimit: { type: "integer", minimum: 1 },@@ -122,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => {- const _account = resolveFeishuAccount({ cfg, accountId });+ const account = resolveFeishuAccount({ cfg, accountId }); const isDefault = accountId === DEFAULT_ACCOUNT_ID; if (isDefault) {@@ -217,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { cfg.channels as Record<string, { groupPolicy?: string }> | undefined )?.defaults?.groupPolicy; const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";- if (groupPolicy !== "open") {- return [];- }+ if (groupPolicy !== "open") return []; return [ `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, ];@@ -6,10 +6,17 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, } from "openclaw/plugin-sdk";-import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";+import type {+ FeishuConfig,+ FeishuMessageContext,+ FeishuMediaInfo,+ ResolvedFeishuAccount,+} from "./types.js";+import type { DynamicAgentCreationConfig } from "./types.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js";-import { downloadMessageResourceFeishu } from "./media.js";+import { maybeCreateDynamicAgent } from "./dynamic-agent.js";+import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js"; import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js"; import { resolveFeishuGroupConfig,@@ -21,6 +28,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu } from "./send.js"; +// --- Message deduplication ---+// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.+const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes+const DEDUP_MAX_SIZE = 1_000;+const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes+const processedMessageIds = new Map<string, number>(); // messageId -> timestamp+let lastCleanupTime = Date.now();++function tryRecordMessage(messageId: string): boolean {+ const now = Date.now();++ // Throttled cleanup: evict expired entries at most once per interval+ if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {+ for (const [id, ts] of processedMessageIds) {+ if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);+ }+ lastCleanupTime = now;+ }++ if (processedMessageIds.has(messageId)) return false;++ // Evict oldest entries if cache is full+ if (processedMessageIds.size >= DEDUP_MAX_SIZE) {+ const first = processedMessageIds.keys().next().value!;+ processedMessageIds.delete(first);+ }++ processedMessageIds.set(messageId, now);+ return true;+}+ // --- Permission error extraction --- // Extract permission grant URL from Feishu API error response. type PermissionError = {@@ -30,16 +68,12 @@ type PermissionError = { }; function extractPermissionError(err: unknown): PermissionError | null {- if (!err || typeof err !== "object") {- return null;- }+ if (!err || typeof err !== "object") return null; // Axios error structure: err.response.data contains the Feishu error const axiosErr = err as { response?: { data?: unknown } }; const data = axiosErr.response?.data;- if (!data || typeof data !== "object") {- return null;- }+ if (!data || typeof data !== "object") return null; const feishuErr = data as { code?: number;@@ -48,9 +82,7 @@ function extractPermissionError(err: unknown): PermissionError | null { }; // Feishu permission error code: 99991672- if (feishuErr.code !== 99991672) {- return null;- }+ if (feishuErr.code !== 99991672) return null; // Extract the grant URL from the error message (contains the direct link) const msg = feishuErr.msg ?? "";@@ -82,28 +114,20 @@ type SenderNameResult = { async function resolveFeishuSenderName(params: {@@ -0,0 +1,155 @@+import fs from "node:fs";+import os from "node:os";+import path from "node:path";+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";+import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";+import { maybeCreateDynamicAgent } from "./dynamic-agent.js";++let tempRoot: string;++beforeEach(async () => {+ tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-agent-"));+});++afterEach(async () => {+ await fs.promises.rm(tempRoot, { recursive: true, force: true });+});++function createRuntime() {+ const replaceConfigFile = vi.fn(async () => {});+ return {+ runtime: {+ config: {+ replaceConfigFile,+ },+ } as unknown as PluginRuntime,+ replaceConfigFile,+ };+}++function createDynamicConfig() {+ return {+ enabled: true,+ workspaceTemplate: path.join(tempRoot, "workspace-{agentId}"),+ agentDirTemplate: path.join(tempRoot, "agent-{agentId}"),+ };+}++async function pathExists(target: string): Promise<boolean> {+ return fs.promises+ .stat(target)+ .then(() => true)+ .catch((err: unknown) => {+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {+ return false;+ }+ throw err;+ });+}++describe("maybeCreateDynamicAgent", () => {+ it("does not persist dynamic agents when config writes are disabled", async () => {+ const { runtime, replaceConfigFile } = createRuntime();+ const dynamicCfg = createDynamicConfig();++ const result = await maybeCreateDynamicAgent({+ cfg: {+ channels: { feishu: { configWrites: false } },+ agents: { list: [] },+ bindings: [],+ } as OpenClawConfig,+ runtime,+ senderOpenId: "ou_sender",+ dynamicCfg,+ configWritesAllowed: false,+ log: vi.fn(),+ });++ expect(result).toEqual({+ created: false,+ updatedCfg: {+ channels: { feishu: { configWrites: false } },+ agents: { list: [] },+ bindings: [],+ },+ });+ expect(replaceConfigFile).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 result = await maybeCreateDynamicAgent({+ cfg: {+ agents: { list: [] },+ bindings: [],+ } as OpenClawConfig,+ runtime,@@ -1,3 +1,4 @@+import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime"; import { resolveFeishuRuntimeAccount } from "./accounts.js";@@ -163,6 +164,11 @@ export async function handleFeishuCommentEvent( runtime: core, senderOpenId: turn.senderId, dynamicCfg,+ configWritesAllowed: resolveChannelConfigWrites({+ cfg: params.cfg,+ channelId: "feishu",+ accountId: account.accountId,+ }), log: (message) => log(message), }); if (dynamicResult.created) {@@ -249,6 +249,7 @@ const { mockTouchBinding, mockResolveFeishuReasoningPreviewEnabled, mockTranscribeFirstAudio,+ mockMaybeCreateDynamicAgent, } = vi.hoisted(() => ({ mockCreateFeishuReplyDispatcher: vi.fn(() => ({ dispatcher: createReplyDispatcher(),@@ -284,6 +285,7 @@ const { mockTouchBinding: vi.fn(), mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false), mockTranscribeFirstAudio: vi.fn(),+ mockMaybeCreateDynamicAgent: vi.fn(), })); vi.mock("./reply-dispatcher.js", () => ({@@ -312,6 +314,10 @@ vi.mock("./client.js", () => ({ createFeishuClient: mockCreateFeishuClient, })); +vi.mock("./dynamic-agent.js", () => ({+ maybeCreateDynamicAgent: mockMaybeCreateDynamicAgent,+}));+ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>( "openclaw/plugin-sdk/conversation-runtime",@@ -406,6 +412,7 @@ describe("handleFeishuMessage ACP routing", () => { mockTouchBinding.mockReset(); mockResolveFeishuReasoningPreviewEnabled.mockReset().mockReturnValue(false); mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);+ mockMaybeCreateDynamicAgent.mockReset().mockResolvedValue({ created: false }); mockResolveAgentRoute.mockReset().mockReturnValue({ ...buildDefaultResolveRoute(), sessionKey: "agent:main:feishu:direct:ou_sender_1",@@ -605,6 +612,7 @@ describe("handleFeishuMessage command authorization", () => { mockResolveBoundConversation.mockReset().mockReturnValue(null); mockTouchBinding.mockReset(); mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);+ mockMaybeCreateDynamicAgent.mockReset().mockResolvedValue({ created: false }); mockResolveAgentRoute.mockReturnValue(buildDefaultResolveRoute()); mockCreateFeishuClient.mockReturnValue({ contact: {@@ -679,6 +687,48 @@ describe("handleFeishuMessage command authorization", () => { expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); }); + it("passes disabled config-write policy to dynamic agent creation", async () => {+ mockShouldComputeCommandAuthorized.mockReturnValue(false);++ const cfg: ClawdbotConfig = {+ channels: {+ feishu: {+ dmPolicy: "open",+ allowFrom: ["*"],+ configWrites: false,+ dynamicAgentCreation: {+ enabled: true,+ },+ },+ },+ } as ClawdbotConfig;++ const event: FeishuMessageEvent = {+ sender: {+ sender_id: {+ open_id: "ou-attacker",+ },+ },+ message: {+ message_id: "msg-dynamic-config-writes-disabled",+ chat_id: "oc-dm",+ chat_type: "p2p",+ message_type: "text",+ content: JSON.stringify({ text: "hello" }),+ },+ };++ await dispatchMessage({ cfg, event });++ expect(mockMaybeCreateDynamicAgent).toHaveBeenCalledWith(+ expect.objectContaining({+ senderOpenId: "ou-attacker",+ configWritesAllowed: false,+ }),+ );+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);+ });+ it("blocks open DMs when a restrictive allowlist does not match", async () => {AI-assisted change 4001a774e192769afac82b1844185b64cf2f10edc38538b15124a11cfb16445d · Fix bb390ca13da55abc1fc4f96fdae6bb7e85a3b5f1fb6bec50993db7ff2790332f