Skip to content

Commit e9f9a68

Browse files
fix(weixin): startAccount preserves session routing (#93686)
* fix(channels): resolve manifest account config by normalized id * fix(routing): ignore blocked keys during normalized account lookup * fix(routing): block normalized unsafe account keys * test(routing): type normalized account lookup case * trigger CI * fix account lookup invalid key fallback * fix(weixin): startAccount preserves session routing (#93686) (thanks @zhangguiping-xydt) --------- Co-authored-by: sliverp <870080352@qq.com>
1 parent db255b1 commit e9f9a68

5 files changed

Lines changed: 137 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
Docs: https://docs.openclaw.ai
44

5+
## Unreleased
6+
7+
### Fixes
8+
9+
- **WeChat account routing:** `startAccount` preserves session routing by resolving manifest channel account config from raw account keys with opaque provider ids, while still ignoring manifest account keys that normalize to blocked object keys. (#93686) Thanks @zhangguiping-xydt.
10+
511
## 2026.6.10
612

713
### Highlights

src/channels/plugins/read-only.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,81 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
10751075
expect(inheritedAccount?.config?.token).not.toBe("prototype-token");
10761076
});
10771077

1078+
it("ignores manifest account keys that normalize to blocked object keys", () => {
1079+
const { pluginDir } = writeExternalSetupChannelPlugin({
1080+
setupEntry: false,
1081+
pluginId: "external-chat-plugin",
1082+
channelId: "external-chat",
1083+
manifestChannelConfig: true,
1084+
});
1085+
const cfg = {
1086+
channels: {
1087+
"external-chat": {
1088+
accounts: {
1089+
"constructor ": {
1090+
token: "blocked-token",
1091+
},
1092+
},
1093+
},
1094+
},
1095+
plugins: {
1096+
load: { paths: [pluginDir] },
1097+
allow: ["external-chat-plugin"],
1098+
},
1099+
} as never;
1100+
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
1101+
env: { ...process.env },
1102+
includePersistedAuthState: false,
1103+
}).find((entry) => entry.id === "external-chat");
1104+
1105+
expect(plugin?.config.listAccountIds(cfg)).toEqual([]);
1106+
const account = plugin?.config.resolveAccount(cfg, "default");
1107+
const accountFields = expectRecordFields(account, {
1108+
accountId: "default",
1109+
});
1110+
const configFields = expectRecordFields(accountFields.config, {});
1111+
expect(configFields.token).toBeUndefined();
1112+
});
1113+
1114+
it("resolves manifest channel account config from raw account keys with opaque provider ids", () => {
1115+
const { pluginDir } = writeExternalSetupChannelPlugin({
1116+
setupEntry: false,
1117+
pluginId: "external-chat-plugin",
1118+
channelId: "external-chat",
1119+
manifestChannelConfig: true,
1120+
});
1121+
const cfg = {
1122+
channels: {
1123+
"external-chat": {
1124+
accounts: {
1125+
"59000514e8ad@im.bot": {
1126+
enabled: true,
1127+
baseUrl: "https://ilinkai.weixin.qq.com",
1128+
},
1129+
},
1130+
},
1131+
},
1132+
plugins: {
1133+
load: { paths: [pluginDir] },
1134+
allow: ["external-chat-plugin"],
1135+
},
1136+
} as never;
1137+
const plugin = listReadOnlyChannelPluginsForConfig(cfg, {
1138+
env: { ...process.env },
1139+
includePersistedAuthState: false,
1140+
}).find((entry) => entry.id === "external-chat");
1141+
1142+
expect(plugin?.config.listAccountIds(cfg)).toEqual(["59000514e8ad-im-bot"]);
1143+
const account = plugin?.config.resolveAccount(cfg, "59000514e8ad-im-bot");
1144+
const fields = expectRecordFields(account, {
1145+
accountId: "59000514e8ad-im-bot",
1146+
});
1147+
expectRecordFields(fields.config, {
1148+
enabled: true,
1149+
baseUrl: "https://ilinkai.weixin.qq.com",
1150+
});
1151+
});
1152+
10781153
it("keeps setup-entry precedence when channel config descriptors are not runtime cutoffs", () => {
10791154
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
10801155
pluginId: "external-chat-plugin",

src/channels/plugins/read-only.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ import {
3535
type PluginModuleLoaderCache,
3636
} from "../../plugins/plugin-module-loader-cache.js";
3737
import { getActivePluginChannelRegistryVersion } from "../../plugins/runtime.js";
38-
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
38+
import { resolveNormalizedAccountEntry } from "../../routing/account-lookup.js";
39+
import {
40+
DEFAULT_ACCOUNT_ID,
41+
normalizeAccountId,
42+
normalizeOptionalAccountId,
43+
} from "../../routing/session-key.js";
3944
import { getBundledChannelSetupPlugin } from "./bundled.js";
4045
import {
4146
isSafeManifestChannelId,
@@ -349,15 +354,19 @@ function getChannelConfigRecord(cfg: OpenClawConfig, channelId: string): Record<
349354
: {};
350355
}
351356

357+
function normalizeManifestAccountConfigKey(accountId: string): string {
358+
return normalizeOptionalAccountId(accountId) ?? "";
359+
}
360+
352361
function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
353362
const channelConfig = getChannelConfigRecord(cfg, channelId);
354363
const accounts = channelConfig.accounts;
355364
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
356365
return sortUniqueStrings(
357366
Object.keys(accounts)
358367
.filter((accountId) => !isBlockedObjectKey(accountId))
359-
.map((accountId) => normalizeAccountId(accountId))
360-
.filter((accountId) => !isBlockedObjectKey(accountId)),
368+
.map((accountId) => normalizeOptionalAccountId(accountId))
369+
.filter((accountId): accountId is string => Boolean(accountId)),
361370
);
362371
}
363372
return hasExplicitChannelConfig({ config: cfg, channelId }) ? [DEFAULT_ACCOUNT_ID] : [];
@@ -372,9 +381,10 @@ function resolveManifestChannelAccountConfig(params: {
372381
const resolvedAccountId = normalizeAccountId(params.accountId);
373382
const accounts = channelConfig.accounts;
374383
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
375-
const accountConfig = readOwnRecordValue(
384+
const accountConfig = resolveNormalizedAccountEntry(
376385
accounts as Record<string, unknown>,
377386
resolvedAccountId,
387+
normalizeManifestAccountConfigKey,
378388
);
379389
if (accountConfig && typeof accountConfig === "object" && !Array.isArray(accountConfig)) {
380390
return accountConfig as Record<string, unknown>;

src/routing/account-lookup.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Account lookup tests cover account matching by id, alias, and chat metadata.
22
import { describe, expect, it } from "vitest";
3+
import { normalizeAccountId as normalizeRoutingAccountId } from "./account-id.js";
34
import { resolveAccountEntry, resolveNormalizedAccountEntry } from "./account-lookup.js";
45

56
function createAccountsWithPrototypePollution() {
@@ -75,6 +76,33 @@ describe("resolveNormalizedAccountEntry", () => {
7576
id: "ops",
7677
},
7778
},
79+
{
80+
name: "does not resolve blocked raw keys as the default account",
81+
accounts: JSON.parse('{"__proto__":{"id":"blocked"}}') as Record<string, { id: string }>,
82+
resolve: (accounts: Record<string, { id: string }>) =>
83+
resolveNormalizedAccountEntry(accounts, "default", normalizeRoutingAccountId),
84+
expected: undefined,
85+
},
86+
{
87+
name: "does not resolve keys that normalize to blocked object keys",
88+
accounts: {
89+
"constructor ": { id: "blocked" },
90+
} as Record<string, { id: string }>,
91+
resolve: (accounts: Record<string, { id: string }>) =>
92+
resolveNormalizedAccountEntry(accounts, "constructor", (accountId) =>
93+
accountId.trim().toLowerCase(),
94+
),
95+
expected: undefined,
96+
},
97+
{
98+
name: "does not resolve invalid raw keys through the default account fallback",
99+
accounts: {
100+
"constructor ": { id: "blocked" },
101+
} as Record<string, { id: string }>,
102+
resolve: (accounts: Record<string, { id: string }>) =>
103+
resolveNormalizedAccountEntry(accounts, "default", normalizeRoutingAccountId),
104+
expected: undefined,
105+
},
78106
{
79107
name: "ignores prototype-chain values",
80108
resolve: () => undefined,

src/routing/account-lookup.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Account lookup helpers resolve route accounts from normalized account ids.
22
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
3+
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
4+
import { normalizeOptionalAccountId } from "./account-id.js";
35

46
// Case-insensitive account lookup for config maps that may preserve user
57
// casing. Exact keys win so callers can still distinguish intentional entries.
@@ -30,10 +32,20 @@ export function resolveNormalizedAccountEntry<T>(
3032
if (!accounts || typeof accounts !== "object") {
3133
return undefined;
3234
}
33-
if (Object.hasOwn(accounts, accountId)) {
35+
if (Object.hasOwn(accounts, accountId) && !isBlockedObjectKey(accountId)) {
3436
return accounts[accountId];
3537
}
3638
const normalized = normalizeAccountId(accountId);
37-
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
39+
const matchKey = Object.keys(accounts).find((key) => {
40+
if (isBlockedObjectKey(key)) {
41+
return false;
42+
}
43+
const candidate = normalizeAccountId(key);
44+
return (
45+
Boolean(normalizeOptionalAccountId(key)) &&
46+
!isBlockedObjectKey(candidate) &&
47+
candidate === normalized
48+
);
49+
});
3850
return matchKey ? accounts[matchKey] : undefined;
3951
}

0 commit comments

Comments
 (0)