Skip to content

Commit b22c899

Browse files
authored
fix(doctor): discover load-path plugin contracts (#77477)
Merged via squash. Prepared head SHA: d428fd4 Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman
1 parent 1692264 commit b22c899

14 files changed

Lines changed: 382 additions & 14 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ Docs: https://docs.openclaw.ai
454454
- Agents/compaction: disable Pi auto-compaction whenever OpenClaw effectively owns safeguard compaction, including provider-backed safeguard mode, so Pi and OpenClaw no longer fight over long-session compaction. Fixes #73003. (#73839) Thanks @bradhallett.
455455
- Telegram/streaming: finalize text replies by stopping the edited stream message instead of sending a second answer bubble, so Telegram turns cannot duplicate the streamed final response. (#77947) Thanks @obviyus.
456456
- web_search/Brave: fix provider selection when Brave is installed as an external plugin and `tools.web.search.provider: "brave"` is explicitly configured — a redundant provider re-resolution at startup could race and return an empty list, causing a spurious `WEB_SEARCH_PROVIDER_INVALID_AUTODETECT` warning and treating the explicitly configured provider as absent. Fixes #77676. Thanks @openperf.
457+
- Doctor/plugins: discover doctor contracts from load-path channel plugins during `openclaw doctor --fix`, so plugin-owned legacy config repair runs before validation. (#77477) Thanks @jalehman.
457458

458459
## 2026.5.3-1
459460

extensions/googlechat/src/channel.deps.runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
PAIRING_APPROVED_MESSAGE,
1010
resolveChannelMediaMaxBytes,
1111
type ChannelMessageActionAdapter,
12+
type ChannelMessageActionName,
1213
type ChannelStatusIssue,
1314
type OpenClawConfig,
1415
} from "../runtime-api.js";

src/agents/tools/cron-tool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
735735
const includeDisabled = Boolean(params.includeDisabled);
736736
let offset = 0;
737737
let result: unknown;
738-
do {
738+
for (;;) {
739739
result = await callGateway("cron.list", gatewayOpts, {
740740
includeDisabled,
741741
agentId: listAgentId,
@@ -749,7 +749,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
749749
break;
750750
}
751751
offset = nextOffset;
752-
} while (true);
752+
}
753753
return jsonResult(
754754
selfRemoveOnlyJobId ? filterCronListResultToJobId(result, selfRemoveOnlyJobId) : result,
755755
);

src/channels/plugins/legacy-config.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,13 @@ describe("collectChannelLegacyConfigRules", () => {
8585
},
8686
]);
8787

88-
const rules = collectChannelLegacyConfigRules({
88+
const config = {
8989
channels: {
9090
slack: {},
9191
"custom-chat": {},
9292
},
93-
});
93+
};
94+
const rules = collectChannelLegacyConfigRules(config);
9495

9596
expect(rules).toEqual([
9697
{
@@ -103,6 +104,7 @@ describe("collectChannelLegacyConfigRules", () => {
103104
},
104105
]);
105106
expect(listPluginDoctorLegacyConfigRulesMock).toHaveBeenCalledWith({
107+
config,
106108
pluginIds: ["custom-chat"],
107109
});
108110
});

src/channels/plugins/legacy-config.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { LegacyConfigRule } from "../../config/legacy.shared.js";
2+
import type { OpenClawConfig } from "../../config/types.js";
23
import { listPluginDoctorLegacyConfigRules } from "../../plugins/doctor-contract-registry.js";
34
import { getBootstrapChannelPlugin } from "./bootstrap-registry.js";
45
import { loadBundledChannelDoctorContractApi } from "./doctor-contract-api.js";
@@ -101,7 +102,12 @@ export function collectChannelLegacyConfigRules(
101102
unresolvedChannelIds.push(channelId);
102103
}
103104
if (unresolvedChannelIds.length > 0) {
104-
rules.push(...listPluginDoctorLegacyConfigRules({ pluginIds: unresolvedChannelIds }));
105+
rules.push(
106+
...listPluginDoctorLegacyConfigRules({
107+
config: raw as OpenClawConfig,
108+
pluginIds: unresolvedChannelIds,
109+
}),
110+
);
105111
}
106112

107113
const seen = new Set<string>();

src/commands/doctor-session-state-providers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,10 @@ export function resolveConfiguredDoctorSessionStateRoute(params: {
121121
}
122122

123123
function resolvePluginDoctorSessionRouteStateOwners(params: {
124+
cfg: OpenClawConfig;
124125
env?: NodeJS.ProcessEnv;
125126
}): DoctorSessionRouteStateOwner[] {
126-
return listPluginDoctorSessionRouteStateOwners({ env: params.env });
127+
return listPluginDoctorSessionRouteStateOwners({ config: params.cfg, env: params.env });
127128
}
128129

129130
function entryMayContainPluginSessionRouteState(entry: SessionEntry): boolean {
@@ -460,7 +461,7 @@ export async function runPluginSessionStateDoctorRepairs(params: {
460461
if (!storeMayContainPluginSessionRouteState(params.store)) {
461462
return;
462463
}
463-
const owners = resolvePluginDoctorSessionRouteStateOwners({ env: params.env });
464+
const owners = resolvePluginDoctorSessionRouteStateOwners({ cfg: params.cfg, env: params.env });
464465
if (owners.length === 0) {
465466
return;
466467
}

src/commands/doctor/shared/channel-legacy-config-migrate.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const applyPluginDoctorCompatibilityMigrations = vi.hoisted(() => vi.fn());
3+
const { applyPluginDoctorCompatibilityMigrations, collectRelevantDoctorPluginIds } = vi.hoisted(
4+
() => ({
5+
applyPluginDoctorCompatibilityMigrations: vi.fn(),
6+
collectRelevantDoctorPluginIds: vi.fn(),
7+
}),
8+
);
49
const loadBundledChannelDoctorContractApi = vi.hoisted(() => vi.fn());
510
const getBootstrapChannelPlugin = vi.hoisted(() => vi.fn());
611

712
vi.mock("../../../plugins/doctor-contract-registry.js", () => ({
813
applyPluginDoctorCompatibilityMigrations: (...args: unknown[]) =>
914
applyPluginDoctorCompatibilityMigrations(...args),
15+
collectRelevantDoctorPluginIds: (...args: unknown[]) => collectRelevantDoctorPluginIds(...args),
1016
}));
1117

1218
vi.mock("../../../channels/plugins/doctor-contract-api.js", () => ({
@@ -30,12 +36,14 @@ beforeAll(async () => {
3036

3137
beforeEach(() => {
3238
applyPluginDoctorCompatibilityMigrations.mockReset();
39+
collectRelevantDoctorPluginIds.mockReset();
3340
loadBundledChannelDoctorContractApi.mockReset();
3441
getBootstrapChannelPlugin.mockReset();
3542
});
3643

3744
describe("bundled channel legacy config migrations", () => {
3845
it("prefers bundled channel doctor contract normalizers before plugin registry fallback", () => {
46+
collectRelevantDoctorPluginIds.mockReturnValueOnce([]);
3947
loadBundledChannelDoctorContractApi.mockImplementation((channelId: string) =>
4048
channelId === "slack"
4149
? {
@@ -82,6 +90,7 @@ describe("bundled channel legacy config migrations", () => {
8290
});
8391

8492
it("normalizes legacy private-network aliases exposed through bundled contract surfaces", () => {
93+
collectRelevantDoctorPluginIds.mockReturnValueOnce(["mattermost"]);
8594
loadBundledChannelDoctorContractApi.mockReturnValue(undefined);
8695
getBootstrapChannelPlugin.mockReturnValue(undefined);
8796
applyPluginDoctorCompatibilityMigrations.mockReturnValueOnce({
@@ -121,6 +130,7 @@ describe("bundled channel legacy config migrations", () => {
121130
});
122131

123132
expect(applyPluginDoctorCompatibilityMigrations).toHaveBeenCalledWith(expect.any(Object), {
133+
config: expect.any(Object),
124134
pluginIds: ["mattermost"],
125135
});
126136

@@ -147,4 +157,42 @@ describe("bundled channel legacy config migrations", () => {
147157
]),
148158
);
149159
});
160+
161+
it("applies plugin doctor normalizers for configured non-channel plugin entries", () => {
162+
collectRelevantDoctorPluginIds.mockReturnValueOnce(["lossless-claw"]);
163+
applyPluginDoctorCompatibilityMigrations.mockReturnValueOnce({
164+
config: {
165+
plugins: {
166+
entries: {
167+
"lossless-claw": {
168+
llm: {
169+
allowModelOverride: true,
170+
allowedModels: ["openai-codex/gpt-5.4-mini"],
171+
},
172+
},
173+
},
174+
},
175+
},
176+
changes: ["Configured plugins.entries.lossless-claw.llm.allowedModels."],
177+
});
178+
179+
const config = {
180+
plugins: {
181+
entries: {
182+
"lossless-claw": {
183+
config: {
184+
summaryModel: "openai-codex/gpt-5.4-mini",
185+
},
186+
},
187+
},
188+
},
189+
};
190+
const result = applyChannelDoctorCompatibilityMigrations(config);
191+
192+
expect(applyPluginDoctorCompatibilityMigrations).toHaveBeenCalledWith(expect.any(Object), {
193+
config,
194+
pluginIds: ["lossless-claw"],
195+
});
196+
expect(result.changes).toEqual(["Configured plugins.entries.lossless-claw.llm.allowedModels."]);
197+
});
150198
});

src/commands/doctor/shared/channel-legacy-config-migrate.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { getBootstrapChannelPlugin } from "../../../channels/plugins/bootstrap-registry.js";
22
import { loadBundledChannelDoctorContractApi } from "../../../channels/plugins/doctor-contract-api.js";
33
import type { OpenClawConfig } from "../../../config/types.js";
4-
import { applyPluginDoctorCompatibilityMigrations } from "../../../plugins/doctor-contract-registry.js";
4+
import {
5+
applyPluginDoctorCompatibilityMigrations,
6+
collectRelevantDoctorPluginIds,
7+
} from "../../../plugins/doctor-contract-registry.js";
58
import { isRecord } from "./legacy-config-record-shared.js";
69

710
type ChannelDoctorCompatibilityMutation = {
@@ -34,6 +37,21 @@ function resolveBundledChannelCompatibilityNormalizer(
3437
return getBootstrapChannelPlugin(channelId)?.doctor?.normalizeCompatibilityConfig;
3538
}
3639

40+
function collectPluginDoctorCompatibilityIds(params: {
41+
raw: unknown;
42+
unresolvedChannelIds: readonly string[];
43+
}): string[] {
44+
const unresolvedChannelIds = new Set(params.unresolvedChannelIds);
45+
return [
46+
...new Set([
47+
...params.unresolvedChannelIds,
48+
...collectRelevantDoctorPluginIds(params.raw).filter(
49+
(pluginId) => !unresolvedChannelIds.has(pluginId),
50+
),
51+
]),
52+
].toSorted();
53+
}
54+
3755
export function applyChannelDoctorCompatibilityMigrations(cfg: Record<string, unknown>): {
3856
next: Record<string, unknown>;
3957
changes: string[];
@@ -56,9 +74,11 @@ export function applyChannelDoctorCompatibilityMigrations(cfg: Record<string, un
5674
changes.push(...mutation.changes);
5775
}
5876

59-
if (unresolvedChannelIds.length > 0) {
77+
const pluginIds = collectPluginDoctorCompatibilityIds({ raw: cfg, unresolvedChannelIds });
78+
if (pluginIds.length > 0) {
6079
const compat = applyPluginDoctorCompatibilityMigrations(nextCfg, {
61-
pluginIds: unresolvedChannelIds,
80+
config: cfg as OpenClawConfig,
81+
pluginIds,
6282
});
6383
nextCfg = compat.config;
6484
changes.push(...compat.changes);

src/commands/doctor/shared/legacy-config-issues.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { collectChannelLegacyConfigRules } from "../../../channels/plugins/legacy-config.js";
22
import { findLegacyConfigIssues } from "../../../config/legacy.js";
33
import type { LegacyConfigRule } from "../../../config/legacy.shared.js";
4-
import type { LegacyConfigIssue } from "../../../config/types.js";
4+
import type { LegacyConfigIssue, OpenClawConfig } from "../../../config/types.js";
55
import {
66
collectRelevantDoctorPluginIds,
77
collectRelevantDoctorPluginIdsForTouchedPaths,
@@ -32,7 +32,7 @@ function collectPluginLegacyConfigRules(
3232
if (pluginIds.length === 0) {
3333
return [];
3434
}
35-
return listPluginDoctorLegacyConfigRules({ pluginIds });
35+
return listPluginDoctorLegacyConfigRules({ config: raw as OpenClawConfig, pluginIds });
3636
}
3737

3838
export function findDoctorLegacyConfigIssues(

src/plugin-sdk/fetch-auth.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ describe("fetchWithBearerAuthScopeFallback", () => {
125125
enumerable: false,
126126
});
127127
const fetchFn = vi.fn(async (_url: string, init?: RequestInit) => {
128-
new Headers(init?.headers);
128+
const normalizedHeaders = new Headers(init?.headers);
129+
expect(normalizedHeaders.get("accept")).toBe("application/json");
129130
return fetchFn.mock.calls.length === 1
130131
? new Response("unauthorized", { status: 401 })
131132
: new Response("ok", { status: 200 });

0 commit comments

Comments
 (0)