Skip to content

Commit cce1269

Browse files
authored
fix(doctor): materialize group allowFrom fallback (#82316)
* fix(doctor): materialize group allowFrom fallback * fix: normalize doctor account records
1 parent 903e246 commit cce1269

9 files changed

Lines changed: 349 additions & 9 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records.
2222
- Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling.
2323
- Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export.
24+
- Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags.
2425
- Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer.
2526
- Plugin releases: require external package compatibility metadata in the npm plugin publish plan, matching the ClawHub package contract before packages ship.
2627
- Agents/OpenAI-compatible: honor per-model `max_completion_tokens`/`max_tokens` params in embedded OpenAI-completions runs so high-token Kimi-style routes keep their configured completion cap. Fixes #82230. Thanks @albert-zen.

extensions/msteams/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"doctorCapabilities": {
4949
"dmAllowFromMode": "topOnly",
5050
"groupModel": "hybrid",
51-
"groupAllowFromFallbackToAllowFrom": false,
51+
"groupAllowFromFallbackToAllowFrom": true,
5252
"warnOnEmptyGroupSenderAllowlist": true
5353
}
5454
},

extensions/msteams/src/channel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
491491
doctor: {
492492
dmAllowFromMode: "topOnly",
493493
groupModel: "hybrid",
494-
groupAllowFromFallbackToAllowFrom: false,
494+
groupAllowFromFallbackToAllowFrom: true,
495495
warnOnEmptyGroupSenderAllowlist: true,
496496
collectMutableAllowlistWarnings: collectMSTeamsMutableAllowlistWarnings,
497497
},

src/commands/doctor-config-flow.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ vi.mock("./doctor/channel-capabilities.js", () => {
654654
msteams: {
655655
dmAllowFromMode: "topOnly",
656656
groupModel: "hybrid",
657-
groupAllowFromFallbackToAllowFrom: false,
657+
groupAllowFromFallbackToAllowFrom: true,
658658
warnOnEmptyGroupSenderAllowlist: true,
659659
},
660660
zalouser: {

src/commands/doctor/channel-capabilities.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe("doctor channel capabilities", () => {
3333
expect(getDoctorChannelCapabilities("msteams")).toEqual({
3434
dmAllowFromMode: "topOnly",
3535
groupModel: "hybrid",
36-
groupAllowFromFallbackToAllowFrom: false,
36+
groupAllowFromFallbackToAllowFrom: true,
3737
warnOnEmptyGroupSenderAllowlist: true,
3838
});
3939
});

src/commands/doctor/repair-sequencing.test.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ const mocks = vi.hoisted(() => ({
99
getInstalledPluginRecord: vi.fn(),
1010
isInstalledPluginEnabled: vi.fn(),
1111
loadInstalledPluginIndex: vi.fn(),
12+
maybeRepairGroupAllowFromFallback: vi.fn(),
1213
maybeRepairManagedNpmOpenClawPeerLinks: vi.fn(),
14+
maybeRepairOpenPolicyAllowFrom: vi.fn(),
1315
maybeRepairStaleManagedNpmBundledPlugins: vi.fn(),
1416
maybeRepairStalePluginConfig: vi.fn(),
1517
repairStaleOAuthProfileShadows: vi.fn(),
@@ -101,6 +103,10 @@ vi.mock("./shared/allowlist-policy-repair.js", () => ({
101103
}),
102104
}));
103105

106+
vi.mock("./shared/allowfrom-fallback-migration.js", () => ({
107+
maybeRepairGroupAllowFromFallback: mocks.maybeRepairGroupAllowFromFallback,
108+
}));
109+
104110
vi.mock("./shared/bundled-plugin-load-paths.js", () => ({
105111
maybeRepairBundledPluginLoadPaths: (cfg: OpenClawConfig) => ({
106112
config: cfg,
@@ -109,10 +115,7 @@ vi.mock("./shared/bundled-plugin-load-paths.js", () => ({
109115
}));
110116

111117
vi.mock("./shared/open-policy-allowfrom.js", () => ({
112-
maybeRepairOpenPolicyAllowFrom: (cfg: OpenClawConfig) => ({
113-
config: cfg,
114-
changes: [],
115-
}),
118+
maybeRepairOpenPolicyAllowFrom: mocks.maybeRepairOpenPolicyAllowFrom,
116119
}));
117120

118121
vi.mock("./shared/stale-plugin-config.js", () => ({
@@ -174,6 +177,13 @@ vi.mock("./shared/exec-safe-bins.js", () => ({
174177
}),
175178
}));
176179

180+
vi.mock("./shared/plugin-dependency-cleanup.js", () => ({
181+
cleanupLegacyPluginDependencyState: async () => ({
182+
changes: [],
183+
warnings: [],
184+
}),
185+
}));
186+
177187
describe("doctor repair sequencing", () => {
178188
beforeEach(() => {
179189
vi.clearAllMocks();
@@ -192,7 +202,15 @@ describe("doctor repair sequencing", () => {
192202
mocks.getInstalledPluginRecord.mockReturnValue(undefined);
193203
mocks.isInstalledPluginEnabled.mockReturnValue(false);
194204
mocks.loadInstalledPluginIndex.mockReturnValue({ plugins: [] });
205+
mocks.maybeRepairGroupAllowFromFallback.mockImplementation((cfg: OpenClawConfig) => ({
206+
config: cfg,
207+
changes: [],
208+
}));
195209
mocks.maybeRepairManagedNpmOpenClawPeerLinks.mockResolvedValue(false);
210+
mocks.maybeRepairOpenPolicyAllowFrom.mockImplementation((cfg: OpenClawConfig) => ({
211+
config: cfg,
212+
changes: [],
213+
}));
196214
mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(false);
197215
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
198216
changes: [],
@@ -444,6 +462,72 @@ describe("doctor repair sequencing", () => {
444462
]);
445463
});
446464

465+
it("runs group allowFrom fallback migration after open-policy allowFrom repair", async () => {
466+
const events: string[] = [];
467+
mocks.maybeRepairOpenPolicyAllowFrom.mockImplementationOnce((cfg: OpenClawConfig) => {
468+
events.push("open-policy");
469+
return {
470+
config: {
471+
...cfg,
472+
channels: {
473+
...cfg.channels,
474+
signal: {
475+
...cfg.channels?.signal,
476+
allowFrom: ["*"],
477+
},
478+
},
479+
},
480+
changes: ['channels.signal.allowFrom: set to ["*"]'],
481+
};
482+
});
483+
mocks.maybeRepairGroupAllowFromFallback.mockImplementationOnce((cfg: OpenClawConfig) => {
484+
events.push("group-fallback");
485+
expect(cfg.channels?.signal?.allowFrom).toEqual(["*"]);
486+
return {
487+
config: {
488+
...cfg,
489+
channels: {
490+
...cfg.channels,
491+
signal: {
492+
...cfg.channels?.signal,
493+
groupAllowFrom: ["*"],
494+
},
495+
},
496+
},
497+
changes: ["channels.signal.groupAllowFrom: copied 1 sender entry from allowFrom"],
498+
};
499+
});
500+
501+
const result = await runDoctorRepairSequence({
502+
state: {
503+
cfg: {
504+
channels: {
505+
signal: {
506+
dmPolicy: "open",
507+
},
508+
},
509+
} as OpenClawConfig,
510+
candidate: {
511+
channels: {
512+
signal: {
513+
dmPolicy: "open",
514+
},
515+
},
516+
} as OpenClawConfig,
517+
pendingChanges: false,
518+
fixHints: [],
519+
},
520+
doctorFixCommand: "openclaw doctor --fix",
521+
});
522+
523+
expect(events).toEqual(["open-policy", "group-fallback"]);
524+
expect(result.state.candidate.channels?.signal?.groupAllowFrom).toEqual(["*"]);
525+
expect(result.changeNotes).toContain('channels.signal.allowFrom: set to ["*"]');
526+
expect(result.changeNotes).toContain(
527+
"channels.signal.groupAllowFrom: copied 1 sender entry from allowFrom",
528+
);
529+
});
530+
447531
it("does not remove deferred configured plugins during the package update doctor pass", async () => {
448532
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({
449533
changes: [

src/commands/doctor/repair-sequencing.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
maybeRepairManagedNpmOpenClawPeerLinks,
55
maybeRepairStaleManagedNpmBundledPlugins,
66
} from "../doctor-plugin-registry.js";
7+
import { maybeRepairGroupAllowFromFallback } from "./shared/allowfrom-fallback-migration.js";
78
import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js";
89
import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js";
910
import {
@@ -66,7 +67,6 @@ export async function runDoctorRepairSequence(params: {
6667
})) {
6768
applyMutation(mutation);
6869
}
69-
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
7070
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env));
7171
maybeRepairStaleManagedNpmBundledPlugins({
7272
config: state.candidate,
@@ -106,6 +106,8 @@ export async function runDoctorRepairSequence(params: {
106106
}
107107
applyMutation(maybeRepairInvalidPluginConfig(state.candidate));
108108
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));
109+
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
110+
applyMutation(maybeRepairGroupAllowFromFallback(state.candidate));
109111

110112
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, {
111113
doctorFixCommand: params.doctorFixCommand,
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { maybeRepairGroupAllowFromFallback } from "./allowfrom-fallback-migration.js";
3+
4+
vi.mock("../channel-capabilities.js", () => ({
5+
getDoctorChannelCapabilities: (channelName?: string) => ({
6+
dmAllowFromMode: channelName === "matrix" ? "nestedOnly" : "topOnly",
7+
groupModel: "sender",
8+
groupAllowFromFallbackToAllowFrom: channelName !== "discord",
9+
warnOnEmptyGroupSenderAllowlist: true,
10+
}),
11+
}));
12+
13+
describe("doctor group allowFrom fallback migration", () => {
14+
it("copies fallback allowFrom into explicit groupAllowFrom", () => {
15+
const result = maybeRepairGroupAllowFromFallback({
16+
channels: {
17+
telegram: {
18+
allowFrom: [123, "accessGroup:ops", "123"],
19+
groupPolicy: "allowlist",
20+
},
21+
},
22+
});
23+
24+
expect(result.changes).toEqual([
25+
"channels.telegram.groupAllowFrom: copied 2 sender entries from allowFrom for explicit group allowlist.",
26+
]);
27+
expect(result.config.channels?.telegram?.groupAllowFrom).toEqual(["123", "accessGroup:ops"]);
28+
});
29+
30+
it("uses canonical nested dm allowFrom for nested channels", () => {
31+
const result = maybeRepairGroupAllowFromFallback({
32+
channels: {
33+
matrix: {
34+
allowFrom: ["@legacy:example.org"],
35+
dm: {
36+
allowFrom: ["@alice:example.org"],
37+
},
38+
},
39+
},
40+
});
41+
42+
expect(result.changes).toEqual([
43+
"channels.matrix.groupAllowFrom: copied 1 sender entry from allowFrom for explicit group allowlist.",
44+
]);
45+
expect(result.config.channels?.matrix?.groupAllowFrom).toEqual(["@alice:example.org"]);
46+
});
47+
48+
it("preserves account-scoped fallback without broadening to the channel", () => {
49+
const result = maybeRepairGroupAllowFromFallback({
50+
channels: {
51+
signal: {
52+
allowFrom: ["parent"],
53+
accounts: {
54+
work: { allowFrom: ["work-user"] },
55+
personal: { groupAllowFrom: ["personal-user"], allowFrom: ["ignored"] },
56+
},
57+
},
58+
},
59+
});
60+
61+
expect(result.changes).toEqual([
62+
"channels.signal.groupAllowFrom: copied 1 sender entry from allowFrom for explicit group allowlist.",
63+
"channels.signal.accounts.work.groupAllowFrom: copied 1 sender entry from allowFrom for explicit group allowlist.",
64+
]);
65+
expect(result.config.channels?.signal?.groupAllowFrom).toEqual(["parent"]);
66+
expect(result.config.channels?.signal?.accounts?.work?.groupAllowFrom).toEqual(["work-user"]);
67+
expect(result.config.channels?.signal?.accounts?.personal?.groupAllowFrom).toEqual([
68+
"personal-user",
69+
]);
70+
});
71+
72+
it("does not shadow an inherited channel group allowlist for accounts", () => {
73+
const result = maybeRepairGroupAllowFromFallback({
74+
channels: {
75+
telegram: {
76+
allowFrom: ["dm-user"],
77+
groupAllowFrom: ["group-user"],
78+
accounts: {
79+
work: { allowFrom: ["work-dm-user"] },
80+
},
81+
},
82+
},
83+
});
84+
85+
expect(result).toEqual({
86+
config: {
87+
channels: {
88+
telegram: {
89+
allowFrom: ["dm-user"],
90+
groupAllowFrom: ["group-user"],
91+
accounts: {
92+
work: { allowFrom: ["work-dm-user"] },
93+
},
94+
},
95+
},
96+
},
97+
changes: [],
98+
});
99+
});
100+
101+
it("skips disabled channels, disabled accounts, and channels without fallback", () => {
102+
const cfg = {
103+
channels: {
104+
disabled: { enabled: false, allowFrom: ["user"] },
105+
telegram: {
106+
accounts: {
107+
disabled: { enabled: false, allowFrom: ["user"] },
108+
},
109+
},
110+
discord: { allowFrom: ["user"] },
111+
tools: { allowFrom: ["user"] },
112+
},
113+
};
114+
115+
expect(maybeRepairGroupAllowFromFallback(cfg)).toEqual({ config: cfg, changes: [] });
116+
});
117+
});

0 commit comments

Comments
 (0)