Skip to content

Commit 9e972d8

Browse files
committed
refactor(doctor): extract allowlist policy repair
1 parent c6ae000 commit 9e972d8

File tree

2 files changed

+160
-154
lines changed

2 files changed

+160
-154
lines changed

src/commands/doctor-config-flow.ts

Lines changed: 1 addition & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import {
3333
detectPluginInstallPathIssue,
3434
formatPluginInstallPathIssue,
3535
} from "../infra/plugin-install-path-warnings.js";
36-
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
3736
import {
3837
formatChannelAccountsDefaultPath,
3938
formatSetExplicitDefaultInstruction,
@@ -62,6 +61,7 @@ import {
6261
maybeRepairTelegramAllowFromUsernames,
6362
scanTelegramAllowFromUsernameEntries,
6463
} from "./doctor/providers/telegram.js";
64+
import { maybeRepairAllowlistPolicyAllowFrom } from "./doctor/shared/allowlist-policy-repair.js";
6565
import { hasAllowFromEntries } from "./doctor/shared/allowlist.js";
6666
import { collectEmptyAllowlistPolicyWarningsForAccount } from "./doctor/shared/empty-allowlist-policy.js";
6767
import { scanMutableAllowlistEntries } from "./doctor/shared/mutable-allowlist.js";
@@ -261,159 +261,6 @@ async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<st
261261
}).map((entry) => `- ${entry}`);
262262
}
263263

264-
async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig): Promise<{
265-
config: OpenClawConfig;
266-
changes: string[];
267-
}> {
268-
const channels = cfg.channels;
269-
if (!channels || typeof channels !== "object") {
270-
return { config: cfg, changes: [] };
271-
}
272-
273-
type AllowFromMode = "topOnly" | "topOrNested" | "nestedOnly";
274-
275-
const resolveAllowFromMode = (channelName: string): AllowFromMode => {
276-
if (channelName === "googlechat") {
277-
return "nestedOnly";
278-
}
279-
if (channelName === "discord" || channelName === "slack") {
280-
return "topOrNested";
281-
}
282-
return "topOnly";
283-
};
284-
285-
const next = structuredClone(cfg);
286-
const changes: string[] = [];
287-
288-
const applyRecoveredAllowFrom = (params: {
289-
account: Record<string, unknown>;
290-
allowFrom: string[];
291-
mode: AllowFromMode;
292-
prefix: string;
293-
}) => {
294-
const count = params.allowFrom.length;
295-
const noun = count === 1 ? "entry" : "entries";
296-
297-
if (params.mode === "nestedOnly") {
298-
const dmEntry = params.account.dm;
299-
const dm =
300-
dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry)
301-
? (dmEntry as Record<string, unknown>)
302-
: {};
303-
dm.allowFrom = params.allowFrom;
304-
params.account.dm = dm;
305-
changes.push(
306-
`- ${params.prefix}.dm.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`,
307-
);
308-
return;
309-
}
310-
311-
if (params.mode === "topOrNested") {
312-
const dmEntry = params.account.dm;
313-
const dm =
314-
dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry)
315-
? (dmEntry as Record<string, unknown>)
316-
: undefined;
317-
const nestedAllowFrom = dm?.allowFrom as Array<string | number> | undefined;
318-
if (dm && !Array.isArray(params.account.allowFrom) && Array.isArray(nestedAllowFrom)) {
319-
dm.allowFrom = params.allowFrom;
320-
changes.push(
321-
`- ${params.prefix}.dm.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`,
322-
);
323-
return;
324-
}
325-
}
326-
327-
params.account.allowFrom = params.allowFrom;
328-
changes.push(
329-
`- ${params.prefix}.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`,
330-
);
331-
};
332-
333-
const recoverAllowFromForAccount = async (params: {
334-
channelName: string;
335-
account: Record<string, unknown>;
336-
accountId?: string;
337-
prefix: string;
338-
}) => {
339-
const dmEntry = params.account.dm;
340-
const dm =
341-
dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry)
342-
? (dmEntry as Record<string, unknown>)
343-
: undefined;
344-
const dmPolicy =
345-
(params.account.dmPolicy as string | undefined) ?? (dm?.policy as string | undefined);
346-
if (dmPolicy !== "allowlist") {
347-
return;
348-
}
349-
350-
const topAllowFrom = params.account.allowFrom as Array<string | number> | undefined;
351-
const nestedAllowFrom = dm?.allowFrom as Array<string | number> | undefined;
352-
if (hasAllowFromEntries(topAllowFrom) || hasAllowFromEntries(nestedAllowFrom)) {
353-
return;
354-
}
355-
356-
const normalizedChannelId = (normalizeChatChannelId(params.channelName) ?? params.channelName)
357-
.trim()
358-
.toLowerCase();
359-
if (!normalizedChannelId) {
360-
return;
361-
}
362-
const normalizedAccountId = normalizeAccountId(params.accountId) || DEFAULT_ACCOUNT_ID;
363-
const fromStore = await readChannelAllowFromStore(
364-
normalizedChannelId,
365-
process.env,
366-
normalizedAccountId,
367-
).catch(() => []);
368-
const recovered = Array.from(new Set(fromStore.map((entry) => String(entry).trim()))).filter(
369-
Boolean,
370-
);
371-
if (recovered.length === 0) {
372-
return;
373-
}
374-
375-
applyRecoveredAllowFrom({
376-
account: params.account,
377-
allowFrom: recovered,
378-
mode: resolveAllowFromMode(params.channelName),
379-
prefix: params.prefix,
380-
});
381-
};
382-
383-
const nextChannels = next.channels as Record<string, Record<string, unknown>>;
384-
for (const [channelName, channelConfig] of Object.entries(nextChannels)) {
385-
if (!channelConfig || typeof channelConfig !== "object") {
386-
continue;
387-
}
388-
await recoverAllowFromForAccount({
389-
channelName,
390-
account: channelConfig,
391-
prefix: `channels.${channelName}`,
392-
});
393-
394-
const accounts = channelConfig.accounts as Record<string, Record<string, unknown>> | undefined;
395-
if (!accounts || typeof accounts !== "object") {
396-
continue;
397-
}
398-
for (const [accountId, accountConfig] of Object.entries(accounts)) {
399-
if (!accountConfig || typeof accountConfig !== "object") {
400-
continue;
401-
}
402-
await recoverAllowFromForAccount({
403-
channelName,
404-
account: accountConfig,
405-
accountId,
406-
prefix: `channels.${channelName}.accounts.${accountId}`,
407-
});
408-
}
409-
}
410-
411-
if (changes.length === 0) {
412-
return { config: cfg, changes: [] };
413-
}
414-
return { config: next, changes };
415-
}
416-
417264
/**
418265
* Scan all channel configs for dmPolicy="allowlist" without any allowFrom entries.
419266
* This configuration blocks all DMs because no sender can match the empty
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { normalizeChatChannelId } from "../../../channels/registry.js";
2+
import type { OpenClawConfig } from "../../../config/config.js";
3+
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
4+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
5+
import { hasAllowFromEntries } from "./allowlist.js";
6+
import { asObjectRecord } from "./object.js";
7+
8+
type AllowFromMode = "topOnly" | "topOrNested" | "nestedOnly";
9+
10+
function resolveAllowFromMode(channelName: string): AllowFromMode {
11+
if (channelName === "googlechat") {
12+
return "nestedOnly";
13+
}
14+
if (channelName === "discord" || channelName === "slack") {
15+
return "topOrNested";
16+
}
17+
return "topOnly";
18+
}
19+
20+
export async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig): Promise<{
21+
config: OpenClawConfig;
22+
changes: string[];
23+
}> {
24+
const channels = cfg.channels;
25+
if (!channels || typeof channels !== "object") {
26+
return { config: cfg, changes: [] };
27+
}
28+
29+
const next = structuredClone(cfg);
30+
const changes: string[] = [];
31+
32+
const applyRecoveredAllowFrom = (params: {
33+
account: Record<string, unknown>;
34+
allowFrom: string[];
35+
mode: AllowFromMode;
36+
prefix: string;
37+
}) => {
38+
const count = params.allowFrom.length;
39+
const noun = count === 1 ? "entry" : "entries";
40+
41+
if (params.mode === "nestedOnly") {
42+
const dmEntry = params.account.dm;
43+
const dm =
44+
dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry)
45+
? (dmEntry as Record<string, unknown>)
46+
: {};
47+
dm.allowFrom = params.allowFrom;
48+
params.account.dm = dm;
49+
changes.push(
50+
`- ${params.prefix}.dm.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`,
51+
);
52+
return;
53+
}
54+
55+
if (params.mode === "topOrNested") {
56+
const dmEntry = params.account.dm;
57+
const dm =
58+
dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry)
59+
? (dmEntry as Record<string, unknown>)
60+
: undefined;
61+
const nestedAllowFrom = dm?.allowFrom as Array<string | number> | undefined;
62+
if (dm && !Array.isArray(params.account.allowFrom) && Array.isArray(nestedAllowFrom)) {
63+
dm.allowFrom = params.allowFrom;
64+
changes.push(
65+
`- ${params.prefix}.dm.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`,
66+
);
67+
return;
68+
}
69+
}
70+
71+
params.account.allowFrom = params.allowFrom;
72+
changes.push(
73+
`- ${params.prefix}.allowFrom: restored ${count} sender ${noun} from pairing store (dmPolicy="allowlist").`,
74+
);
75+
};
76+
77+
const recoverAllowFromForAccount = async (params: {
78+
channelName: string;
79+
account: Record<string, unknown>;
80+
accountId?: string;
81+
prefix: string;
82+
}) => {
83+
const dmEntry = params.account.dm;
84+
const dm =
85+
dmEntry && typeof dmEntry === "object" && !Array.isArray(dmEntry)
86+
? (dmEntry as Record<string, unknown>)
87+
: undefined;
88+
const dmPolicy =
89+
(params.account.dmPolicy as string | undefined) ?? (dm?.policy as string | undefined);
90+
if (dmPolicy !== "allowlist") {
91+
return;
92+
}
93+
94+
const topAllowFrom = params.account.allowFrom as Array<string | number> | undefined;
95+
const nestedAllowFrom = dm?.allowFrom as Array<string | number> | undefined;
96+
if (hasAllowFromEntries(topAllowFrom) || hasAllowFromEntries(nestedAllowFrom)) {
97+
return;
98+
}
99+
100+
const normalizedChannelId = (normalizeChatChannelId(params.channelName) ?? params.channelName)
101+
.trim()
102+
.toLowerCase();
103+
if (!normalizedChannelId) {
104+
return;
105+
}
106+
const normalizedAccountId = normalizeAccountId(params.accountId) || DEFAULT_ACCOUNT_ID;
107+
const fromStore = await readChannelAllowFromStore(
108+
normalizedChannelId,
109+
process.env,
110+
normalizedAccountId,
111+
).catch(() => []);
112+
const recovered = Array.from(new Set(fromStore.map((entry) => String(entry).trim()))).filter(
113+
Boolean,
114+
);
115+
if (recovered.length === 0) {
116+
return;
117+
}
118+
119+
applyRecoveredAllowFrom({
120+
account: params.account,
121+
allowFrom: recovered,
122+
mode: resolveAllowFromMode(params.channelName),
123+
prefix: params.prefix,
124+
});
125+
};
126+
127+
const nextChannels = next.channels as Record<string, Record<string, unknown>>;
128+
for (const [channelName, channelConfig] of Object.entries(nextChannels)) {
129+
if (!channelConfig || typeof channelConfig !== "object") {
130+
continue;
131+
}
132+
await recoverAllowFromForAccount({
133+
channelName,
134+
account: channelConfig,
135+
prefix: `channels.${channelName}`,
136+
});
137+
138+
const accounts = asObjectRecord(channelConfig.accounts);
139+
if (!accounts) {
140+
continue;
141+
}
142+
for (const [accountId, accountConfig] of Object.entries(accounts)) {
143+
if (!accountConfig || typeof accountConfig !== "object") {
144+
continue;
145+
}
146+
await recoverAllowFromForAccount({
147+
channelName,
148+
account: accountConfig as Record<string, unknown>,
149+
accountId,
150+
prefix: `channels.${channelName}.accounts.${accountId}`,
151+
});
152+
}
153+
}
154+
155+
if (changes.length === 0) {
156+
return { config: cfg, changes: [] };
157+
}
158+
return { config: next, changes };
159+
}

0 commit comments

Comments
 (0)