Skip to content

Commit db5e415

Browse files
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 <[email protected]>
1 parent b9e8e6d commit db5e415

7 files changed

Lines changed: 1079 additions & 305 deletions

File tree

docs/channels/feishu.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,9 @@ Enable `dynamicAgentCreation` to automatically create **isolated agent instances
416416
This is essential for public bots where you want each user to have their own private AI assistant experience.
417417

418418
<Note>
419-
**Account limitation**: `dynamicAgentCreation` currently works with the **default Feishu account only**. Named/multi-account setups are not yet fully supported — dynamic bindings are created without `accountId`, so messages to named accounts may still route to `agent:main`. Track progress in [Issue #42837](https://github.com/openclaw/openclaw/issues/42837).
419+
Dynamic bindings include the normalized Feishu `accountId`, so default and named accounts route each sender to the correct dynamic agent.
420+
421+
If a named account created an unscoped dynamic agent on an older release, that legacy agent still counts toward `maxAgents`. Confirm that it is not used by the default account before removing it, or temporarily increase `maxAgents`; OpenClaw cannot safely infer which account owns ambiguous legacy state.
420422
</Note>
421423

422424
### Quick setup
@@ -447,7 +449,7 @@ This is essential for public bots where you want each user to have their own pri
447449

448450
When a new user sends their first DM:
449451

450-
1. The channel generates a unique `agentId` = `feishu-{user_open_id}`
452+
1. The channel generates a unique `agentId`: `feishu-{user_open_id}` for the default account, or a bounded account-prefixed identity digest for a named account
451453
2. Creates a new workspace at `workspaceTemplate` path
452454
3. Registers the agent and creates a binding for this user
453455
4. The workspace helper ensures bootstrap files (`AGENTS.md`, `SOUL.md`, `USER.md`, etc.) on first access
@@ -464,22 +466,23 @@ When a new user sends their first DM:
464466

465467
Template variables:
466468

467-
- `{agentId}` - the generated agent ID (e.g., `feishu-ou_xxxxxx`)
469+
- `{agentId}` - the generated agent ID (e.g., `feishu-ou_xxxxxx` or `feishu-support-<identity_digest>`)
468470
- `{userId}` - the sender's Feishu open_id (e.g., `ou_xxxxxx`)
469471

470472
### Session scope
471473

472474
`session.dmScope` controls how direct messages are mapped to agent sessions. This is a **global setting** that affects all channels.
473475

474-
| Value | Behavior | Best for |
475-
| -------------------- | --------------------------------------------------------- | ------------------------------------------------------------------ |
476-
| `"main"` | Each user's DM maps to their agent's main session | Single-user bots where you want `USER.md` / `SOUL.md` to auto-load |
477-
| `"per-channel-peer"` | Each (channel + user) combination gets a separate session | Public multi-user bots needing stronger isolation |
476+
| Value | Behavior | Best for |
477+
| ---------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------ |
478+
| `"main"` | Each user's DM maps to their agent's main session | Single-user bots where you want `USER.md` / `SOUL.md` to auto-load |
479+
| `"per-channel-peer"` | Each (channel + user) combination gets a separate session | Public multi-user bots needing stronger isolation |
480+
| `"per-account-channel-peer"` | Each (account + channel + user) combination gets a separate session | Multi-account bots needing account-level session isolation |
478481

479482
**Tradeoff**: Using `"main"` enables automatic bootstrap file loading (`USER.md`, `SOUL.md`, `MEMORY.md`), but means all DMs across all channels share the same session key pattern. For public multi-user bots where isolation matters more than bootstrap auto-loading, consider `"per-channel-peer"` and manage bootstrap files manually.
480483

481484
<Note>
482-
`"per-account-channel-peer"` is not recommended with `dynamicAgentCreation` because dynamic bindings are created without `accountId`. Use it only with manual bindings.
485+
Use `"per-account-channel-peer"` when named Feishu accounts should keep separate sessions for the same sender. Dynamic bindings preserve the account scope.
483486
</Note>
484487

485488
```json5

extensions/feishu/src/bot.test.ts

Lines changed: 207 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,13 @@ function buildDefaultResolveRoute(): ResolvedAgentRoute {
163163
matchedBy: "default",
164164
};
165165
}
166+
let currentRuntimeConfig = {} as ClawdbotConfig;
167+
166168
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
167169
return {
170+
config: {
171+
current: vi.fn(() => currentRuntimeConfig),
172+
},
168173
channel: {
169174
routing: {
170175
resolveAgentRoute: resolveAgentRouteMock,
@@ -413,7 +418,11 @@ afterAll(() => {
413418
vi.resetModules();
414419
});
415420

416-
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
421+
async function dispatchMessage(params: {
422+
cfg: ClawdbotConfig;
423+
currentCfg?: ClawdbotConfig;
424+
event: FeishuMessageEvent;
425+
}) {
417426
const runtime = createRuntimeEnv();
418427
const feishuConfig = params.cfg.channels?.feishu;
419428
const cfg =
@@ -429,6 +438,7 @@ async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessa
429438
},
430439
} as ClawdbotConfig)
431440
: params.cfg;
441+
currentRuntimeConfig = params.currentCfg ?? cfg;
432442
await handleFeishuMessage({
433443
cfg,
434444
event: params.event,
@@ -455,7 +465,10 @@ describe("handleFeishuMessage ACP routing", () => {
455465
mockTouchBinding.mockReset();
456466
mockResolveFeishuReasoningPreviewEnabled.mockReset().mockReturnValue(false);
457467
mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
458-
mockMaybeCreateDynamicAgent.mockReset().mockResolvedValue({ created: false });
468+
mockMaybeCreateDynamicAgent.mockReset().mockImplementation(async ({ cfg }) => ({
469+
created: false,
470+
updatedCfg: cfg,
471+
}));
459472
mockResolveAgentRoute.mockReset().mockReturnValue({
460473
...buildDefaultResolveRoute(),
461474
sessionKey: "agent:main:feishu:direct:ou_sender_1",
@@ -976,7 +989,9 @@ describe("handleFeishuMessage command authorization", () => {
976989
},
977990
);
978991
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
979-
const mockShouldComputeCommandAuthorized = vi.fn(() => true);
992+
const mockShouldComputeCommandAuthorized = vi.fn<
993+
PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"]
994+
>(() => true);
980995
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
981996
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
982997
const mockBuildPairingReply = vi.fn(() => "Pairing response");
@@ -1009,7 +1024,10 @@ describe("handleFeishuMessage command authorization", () => {
10091024
mockResolveBoundConversation.mockReset().mockReturnValue(null);
10101025
mockTouchBinding.mockReset();
10111026
mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
1012-
mockMaybeCreateDynamicAgent.mockReset().mockResolvedValue({ created: false });
1027+
mockMaybeCreateDynamicAgent.mockReset().mockImplementation(async ({ cfg }) => ({
1028+
created: false,
1029+
updatedCfg: cfg,
1030+
}));
10131031
mockResolveAgentRoute.mockReturnValue(buildDefaultResolveRoute());
10141032
mockCreateFeishuClient.mockReturnValue({
10151033
contact: {
@@ -1216,7 +1234,7 @@ describe("handleFeishuMessage command authorization", () => {
12161234
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith("dispatch-complete-no-visible-reply");
12171235
});
12181236

1219-
it("passes disabled config-write policy to dynamic agent creation", async () => {
1237+
it("uses refreshed config for dynamic agent dispatch", async () => {
12201238
mockShouldComputeCommandAuthorized.mockReturnValue(false);
12211239

12221240
const cfg: ClawdbotConfig = {
@@ -1231,6 +1249,22 @@ describe("handleFeishuMessage command authorization", () => {
12311249
},
12321250
},
12331251
} as ClawdbotConfig;
1252+
const refreshedCfg = {
1253+
...cfg,
1254+
agents: {
1255+
list: [
1256+
{
1257+
id: "feishu-ou-attacker",
1258+
workspace: "/tmp/feishu-ou-attacker",
1259+
agentDir: "/tmp/feishu-ou-attacker/agent",
1260+
},
1261+
],
1262+
},
1263+
} as ClawdbotConfig;
1264+
mockMaybeCreateDynamicAgent.mockResolvedValueOnce({
1265+
created: false,
1266+
updatedCfg: refreshedCfg,
1267+
});
12341268

12351269
const event: FeishuMessageEvent = {
12361270
sender: {
@@ -1250,12 +1284,177 @@ describe("handleFeishuMessage command authorization", () => {
12501284
await dispatchMessage({ cfg, event });
12511285

12521286
const dynamicAgentRequest = mockCallArg<{
1253-
configWritesAllowed?: boolean;
1287+
accountId?: string;
12541288
senderOpenId?: string;
12551289
}>(mockMaybeCreateDynamicAgent, 0, 0);
12561290
expect(dynamicAgentRequest.senderOpenId).toBe("ou-attacker");
1257-
expect(dynamicAgentRequest.configWritesAllowed).toBe(false);
1258-
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1291+
expect(dynamicAgentRequest.accountId).toBe("default");
1292+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1293+
expect.objectContaining({ cfg: refreshedCfg }),
1294+
);
1295+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledWith(
1296+
expect.objectContaining({ cfg: refreshedCfg }),
1297+
);
1298+
});
1299+
1300+
it("drops a DM denied by refreshed dynamic-agent policy", async () => {
1301+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1302+
1303+
const cfg = {
1304+
channels: {
1305+
feishu: {
1306+
dmPolicy: "open",
1307+
allowFrom: ["*"],
1308+
dynamicAgentCreation: { enabled: true },
1309+
},
1310+
},
1311+
} as ClawdbotConfig;
1312+
const refreshedCfg = {
1313+
channels: {
1314+
feishu: {
1315+
dmPolicy: "allowlist",
1316+
allowFrom: ["ou-admin"],
1317+
dynamicAgentCreation: { enabled: true },
1318+
},
1319+
},
1320+
} as ClawdbotConfig;
1321+
await dispatchMessage({
1322+
cfg,
1323+
currentCfg: refreshedCfg,
1324+
event: {
1325+
sender: { sender_id: { open_id: "ou-attacker" } },
1326+
message: {
1327+
message_id: "msg-refreshed-policy-deny",
1328+
chat_id: "oc-dm",
1329+
chat_type: "p2p",
1330+
message_type: "text",
1331+
content: JSON.stringify({ text: "hello" }),
1332+
},
1333+
},
1334+
});
1335+
1336+
expect(mockMaybeCreateDynamicAgent).not.toHaveBeenCalled();
1337+
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1338+
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
1339+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1340+
});
1341+
1342+
it("reauthorizes current policy before dispatching an existing bound route", async () => {
1343+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1344+
mockResolveAgentRoute.mockReturnValue({
1345+
...buildDefaultResolveRoute(),
1346+
matchedBy: "binding.peer",
1347+
});
1348+
const cfg = {
1349+
channels: { feishu: { dmPolicy: "open", allowFrom: ["*"] } },
1350+
} as ClawdbotConfig;
1351+
const currentCfg = {
1352+
channels: { feishu: { dmPolicy: "allowlist", allowFrom: ["ou-admin"] } },
1353+
} as ClawdbotConfig;
1354+
1355+
await dispatchMessage({
1356+
cfg,
1357+
currentCfg,
1358+
event: {
1359+
sender: { sender_id: { open_id: "ou-attacker" } },
1360+
message: {
1361+
message_id: "msg-bound-refreshed-policy-deny",
1362+
chat_id: "oc-dm",
1363+
chat_type: "p2p",
1364+
message_type: "text",
1365+
content: JSON.stringify({ text: "hello" }),
1366+
},
1367+
},
1368+
});
1369+
1370+
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
1371+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1372+
});
1373+
1374+
it("issues a pairing challenge before dynamic creation when current policy requires it", async () => {
1375+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1376+
mockReadAllowFromStore.mockResolvedValue([]);
1377+
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
1378+
1379+
const cfg = {
1380+
channels: {
1381+
feishu: {
1382+
dmPolicy: "open",
1383+
allowFrom: ["*"],
1384+
dynamicAgentCreation: { enabled: true },
1385+
},
1386+
},
1387+
} as ClawdbotConfig;
1388+
const currentCfg = {
1389+
channels: {
1390+
feishu: {
1391+
dmPolicy: "pairing",
1392+
allowFrom: [],
1393+
dynamicAgentCreation: { enabled: true },
1394+
},
1395+
},
1396+
} as ClawdbotConfig;
1397+
1398+
await dispatchMessage({
1399+
cfg,
1400+
currentCfg,
1401+
event: {
1402+
sender: { sender_id: { open_id: "ou-attacker" } },
1403+
message: {
1404+
message_id: "msg-refreshed-policy-pairing",
1405+
chat_id: "oc-dm",
1406+
chat_type: "p2p",
1407+
message_type: "text",
1408+
content: JSON.stringify({ text: "hello" }),
1409+
},
1410+
},
1411+
});
1412+
1413+
expect(mockMaybeCreateDynamicAgent).not.toHaveBeenCalled();
1414+
expect(mockUpsertPairingRequest).toHaveBeenCalledTimes(1);
1415+
expect(mockSendMessageFeishu).toHaveBeenCalledTimes(1);
1416+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
1417+
});
1418+
1419+
it("recomputes command authorization against refreshed dynamic-agent config", async () => {
1420+
const cfg = {
1421+
channels: {
1422+
feishu: {
1423+
dmPolicy: "open",
1424+
allowFrom: ["*"],
1425+
dynamicAgentCreation: { enabled: true },
1426+
},
1427+
},
1428+
} as ClawdbotConfig;
1429+
const refreshedCfg = {
1430+
...cfg,
1431+
commands: { useAccessGroups: true },
1432+
} as ClawdbotConfig;
1433+
mockShouldComputeCommandAuthorized.mockImplementation((_body, candidateCfg) => {
1434+
return candidateCfg === refreshedCfg;
1435+
});
1436+
mockMaybeCreateDynamicAgent.mockResolvedValueOnce({
1437+
created: false,
1438+
updatedCfg: refreshedCfg,
1439+
});
1440+
1441+
await dispatchMessage({
1442+
cfg,
1443+
event: {
1444+
sender: { sender_id: { open_id: "ou-attacker" } },
1445+
message: {
1446+
message_id: "msg-refreshed-command-auth",
1447+
chat_id: "oc-dm",
1448+
chat_type: "p2p",
1449+
message_type: "text",
1450+
content: JSON.stringify({ text: "/status" }),
1451+
},
1452+
},
1453+
});
1454+
1455+
expect(mockShouldComputeCommandAuthorized).toHaveBeenCalledWith("/status", refreshedCfg);
1456+
const context = mockCallArg<{ CommandAuthorized?: boolean }>(mockFinalizeInboundContext, 0, 0);
1457+
expect(context.CommandAuthorized).toBe(true);
12591458
});
12601459

12611460
it("blocks open DMs when a restrictive allowlist does not match", async () => {

0 commit comments

Comments
 (0)