Skip to content

Commit 50b5771

Browse files
committed
Doctor: warn on partial missing-default account coverage
1 parent 231fa3a commit 50b5771

3 files changed

Lines changed: 103 additions & 9 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { withEnvAsync } from "../test-utils/env.js";
3+
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
4+
5+
const { noteSpy } = vi.hoisted(() => ({
6+
noteSpy: vi.fn(),
7+
}));
8+
9+
vi.mock("../terminal/note.js", () => ({
10+
note: noteSpy,
11+
}));
12+
13+
vi.mock("./doctor-legacy-config.js", async (importOriginal) => {
14+
const actual = await importOriginal<typeof import("./doctor-legacy-config.js")>();
15+
return {
16+
...actual,
17+
normalizeLegacyConfigValues: (cfg: unknown) => ({
18+
config: cfg,
19+
changes: [],
20+
}),
21+
};
22+
});
23+
24+
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
25+
26+
describe("doctor missing default account binding warning", () => {
27+
it("emits a doctor warning when named accounts have no valid account-scoped bindings", async () => {
28+
await withEnvAsync(
29+
{
30+
TELEGRAM_BOT_TOKEN: undefined,
31+
TELEGRAM_BOT_TOKEN_FILE: undefined,
32+
},
33+
async () => {
34+
await runDoctorConfigWithInput({
35+
config: {
36+
channels: {
37+
telegram: {
38+
accounts: {
39+
alerts: {},
40+
work: {},
41+
},
42+
},
43+
},
44+
bindings: [{ agentId: "ops", match: { channel: "telegram" } }],
45+
},
46+
run: loadAndMaybeMigrateDoctorConfig,
47+
});
48+
},
49+
);
50+
51+
expect(noteSpy).toHaveBeenCalledWith(
52+
expect.stringContaining("channels.telegram: accounts.default is missing"),
53+
"Doctor warnings",
54+
);
55+
});
56+
});

src/commands/doctor-config-flow.missing-default-account-bindings.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ describe("collectMissingDefaultAccountBindingWarnings", () => {
3737
expect(collectMissingDefaultAccountBindingWarnings(cfg)).toEqual([]);
3838
});
3939

40+
it("warns when bindings cover only a subset of configured accounts", () => {
41+
const cfg: OpenClawConfig = {
42+
channels: {
43+
telegram: {
44+
accounts: {
45+
alerts: { botToken: "a" },
46+
work: { botToken: "w" },
47+
},
48+
},
49+
},
50+
bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "alerts" } }],
51+
};
52+
53+
const warnings = collectMissingDefaultAccountBindingWarnings(cfg);
54+
expect(warnings).toHaveLength(1);
55+
expect(warnings[0]).toContain("subset");
56+
expect(warnings[0]).toContain("Uncovered accounts: work");
57+
});
58+
4059
it("does not warn when wildcard account binding exists", () => {
4160
const cfg: OpenClawConfig = {
4261
channels: {

src/commands/doctor-config-flow.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -249,33 +249,52 @@ export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig)
249249
const accountIdSet = new Set(normalizedAccountIds);
250250
const channelPattern = normalizeBindingChannelKey(channelKey);
251251

252-
const hasValidBinding = bindings.some((binding) => {
252+
let hasWildcardBinding = false;
253+
const coveredAccountIds = new Set<string>();
254+
for (const binding of bindings) {
253255
const bindingRecord = asObjectRecord(binding);
254256
if (!bindingRecord) {
255-
return false;
257+
continue;
256258
}
257259
const match = asObjectRecord(bindingRecord.match);
258260
if (!match) {
259-
return false;
261+
continue;
260262
}
261263

262264
const matchChannel =
263265
typeof match.channel === "string" ? normalizeBindingChannelKey(match.channel) : "";
264266
if (!matchChannel || matchChannel !== channelPattern) {
265-
return false;
267+
continue;
266268
}
267269

268270
const rawAccountId = typeof match.accountId === "string" ? match.accountId.trim() : "";
269271
if (!rawAccountId) {
270-
return false;
272+
continue;
271273
}
272274
if (rawAccountId === "*") {
273-
return true;
275+
hasWildcardBinding = true;
276+
continue;
274277
}
275-
return accountIdSet.has(normalizeAccountId(rawAccountId));
276-
});
278+
const normalizedBindingAccountId = normalizeAccountId(rawAccountId);
279+
if (accountIdSet.has(normalizedBindingAccountId)) {
280+
coveredAccountIds.add(normalizedBindingAccountId);
281+
}
282+
}
277283

278-
if (hasValidBinding) {
284+
if (hasWildcardBinding) {
285+
continue;
286+
}
287+
288+
const uncoveredAccountIds = normalizedAccountIds.filter(
289+
(accountId) => !coveredAccountIds.has(accountId),
290+
);
291+
if (uncoveredAccountIds.length === 0) {
292+
continue;
293+
}
294+
if (coveredAccountIds.size > 0) {
295+
warnings.push(
296+
`- channels.${channelKey}: accounts.default is missing and account bindings only cover a subset of configured accounts. Uncovered accounts: ${uncoveredAccountIds.join(", ")}. Add bindings[].match.accountId for uncovered accounts (or "*"), or add channels.${channelKey}.accounts.default.`,
297+
);
279298
continue;
280299
}
281300

0 commit comments

Comments
 (0)