improve(doctor): avoid repeated plugin metadata scans (#119482)

* perf(doctor): reuse plugin metadata snapshot

Punchcard-Session: coral-workshop-workshop-3f

* test(doctor): update legacy issue mock

Punchcard-Session: coral-workshop-workshop-3f

* fix(doctor): refresh metadata after repairs

* test(doctor): type legacy issue mock arguments

Punchcard-Session: coral-workshop-workshop-3f

* fix(doctor): invalidate scoped plugin metadata

Punchcard-Session: coral-workshop-workshop-3f

* fix(doctor): keep snapshot scope type private

Punchcard-Session: coral-workshop-workshop-3f

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Vincent Koc
2026-08-05 19:06:08 +08:00
committed by GitHub
parent f68ecec40c
commit 80da61668e
41 changed files with 1941 additions and 455 deletions
+65
View File
@@ -5,6 +5,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { writeChannelPairingStateSnapshot } from "../pairing/pairing-store-sqlite.test-helpers.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import {
@@ -17,6 +18,7 @@ type TerminalNote = (message: string, title?: string) => void;
const terminalNoteMock = vi.hoisted(() => vi.fn<TerminalNote>());
const callGatewayMock = vi.hoisted(() => vi.fn());
const runDoctorRepairSequenceMock = vi.hoisted(() => vi.fn());
const createDoctorPluginMetadataSnapshotScopeParamsMock = vi.hoisted(() => vi.fn());
const runDoctorConfigPreflightOptionsMock = vi.hoisted(() => vi.fn());
const collectDoctorPreviewNotesParamsMock = vi.hoisted(() => vi.fn());
const collectImplicitFallbackClobberWarningsMock = vi.hoisted(() =>
@@ -255,6 +257,21 @@ vi.mock("./doctor/repair-sequencing.js", async () => {
};
});
vi.mock("./doctor/shared/plugin-metadata-snapshot-scope.js", async () => {
const actual = await vi.importActual<
typeof import("./doctor/shared/plugin-metadata-snapshot-scope.js")
>("./doctor/shared/plugin-metadata-snapshot-scope.js");
return {
...actual,
createDoctorPluginMetadataSnapshotScope: (
params: Parameters<typeof actual.createDoctorPluginMetadataSnapshotScope>[0],
) => {
createDoctorPluginMetadataSnapshotScopeParamsMock(params);
return actual.createDoctorPluginMetadataSnapshotScope(params);
},
};
});
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: vi.fn(
({
@@ -1595,6 +1612,7 @@ describe("doctor config flow", () => {
callGatewayMock.mockReset();
callGatewayMock.mockResolvedValue({});
runDoctorRepairSequenceMock.mockReset();
createDoctorPluginMetadataSnapshotScopeParamsMock.mockClear();
collectDoctorPreviewNotesParamsMock.mockClear();
collectImplicitFallbackClobberWarningsMock.mockClear();
collectImplicitFallbackClobberWarningsMock.mockReturnValue([]);
@@ -1901,6 +1919,53 @@ describe("doctor config flow", () => {
);
});
it("prepares plugin metadata for the complete Doctor lifecycle", async () => {
const result = await runDoctorConfigWithInput({
config: {},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ preparePluginMetadataSnapshot: true }),
);
expect(result.runWithPluginMetadataSnapshot).toEqual(expect.any(Function));
expect(result.invalidatePluginMetadataSnapshot).toEqual(expect.any(Function));
expect(collectDoctorPreviewNotesParamsMock).toHaveBeenLastCalledWith(
expect.objectContaining({
runWithPluginMetadataSnapshot: result.runWithPluginMetadataSnapshot,
}),
);
});
it("exposes cleanup-refreshed plugin metadata to later Doctor scopes", async () => {
const refreshedSnapshot = {
plugins: [],
index: { installRecords: {} },
} as unknown as PluginMetadataSnapshot;
runDoctorRepairSequenceMock.mockImplementation(async (params: { state: unknown }) => ({
state: params.state,
changeNotes: ['Removed stale managed install record for bundled plugin "google-meet".'],
warningNotes: [],
authProfilesRepaired: false,
pluginMetadataSnapshot: refreshedSnapshot,
}));
const result = await runDoctorConfigWithInput({
config: {},
repair: true,
run: loadAndMaybeMigrateDoctorConfig,
});
expect(result.pluginMetadataSnapshot).toBe(refreshedSnapshot);
const scopeParams = createDoctorPluginMetadataSnapshotScopeParamsMock.mock.lastCall?.[0] as {
getBaseSnapshot: () => PluginMetadataSnapshot | undefined;
};
expect(scopeParams.getBaseSnapshot()).toBe(refreshedSnapshot);
expect(scopeParams.getBaseSnapshot()?.index.installRecords).not.toHaveProperty("google-meet");
result.invalidatePluginMetadataSnapshot();
expect(scopeParams.getBaseSnapshot()).toBeUndefined();
});
it("collects plugin blocker previews from the pre-auto-enable config", async () => {
await runDoctorConfigWithInput({
config: {
+111 -47
View File
@@ -2,6 +2,7 @@
import path from "node:path";
import { note } from "../../packages/terminal-core/src/note.js";
import { readAgentRosterProperty } from "../agents/agent-scope-config.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { formatCliCommand } from "../cli/command-format.js";
import { configIncludeOwnsAgentRoster } from "../config/agent-roster-provenance.js";
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
@@ -31,6 +32,7 @@ import {
import { materializeDefaultAgentRoles } from "./doctor/shared/default-agent-role-materialization.js";
import { isSingleTopLevelIncludeMigration } from "./doctor/shared/include-migration-ownership.js";
import { normalizeCompatibilityConfigValues } from "./doctor/shared/legacy-config-core-migrate.js";
import type { DoctorPluginMetadataSnapshotState } from "./doctor/shared/plugin-metadata-snapshot-scope.js";
function hasLegacyInternalHookHandlers(raw: unknown): boolean {
const handlers = (raw as { hooks?: { internal?: { handlers?: unknown } } })?.hooks?.internal
@@ -153,9 +155,33 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
repairPrefixedConfig: shouldRepair,
recoverCorruptTargetStore: shouldRepair,
doctorOnlyStateMigrations: shouldRepair,
preparePluginMetadataSnapshot: true,
});
const snapshot = preflight.snapshot;
const baseCfg = preflight.baseConfig;
const pluginMetadataSnapshotState: DoctorPluginMetadataSnapshotState = {
current: preflight.pluginMetadataSnapshot,
};
const { createDoctorPluginMetadataSnapshotScope } =
await import("./doctor/shared/plugin-metadata-snapshot-scope.js");
const pluginMetadataSnapshotScope = createDoctorPluginMetadataSnapshotScope({
getBaseSnapshot: () => pluginMetadataSnapshotState.current,
env: process.env,
});
const runWithPluginMetadataSnapshot = pluginMetadataSnapshotScope.run;
const invalidatePluginMetadataSnapshot = () => {
// Filesystem/install repairs replace the authoritative plugin generation.
pluginMetadataSnapshotState.current = undefined;
pluginMetadataSnapshotScope.invalidate();
};
const runWithCurrentPluginMetadata = <T>(config: OpenClawConfig, run: () => T): T =>
runWithPluginMetadataSnapshot(
{
config,
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)),
},
run,
);
let state: DoctorConfigMutationState = {
cfg: baseCfg,
candidate: structuredClone(baseCfg),
@@ -189,12 +215,14 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const sourceLastTouchedVersion =
typeof sourceMeta?.lastTouchedVersion === "string" ? sourceMeta.lastTouchedVersion : undefined;
const legacyStep = applyLegacyCompatibilityStep({
snapshot,
state,
shouldRepair,
doctorFixCommand,
});
const legacyStep = runWithCurrentPluginMetadata(state.candidate, () =>
applyLegacyCompatibilityStep({
snapshot,
state,
shouldRepair,
doctorFixCommand,
}),
);
state = legacyStep.state;
const legacyMigrationPartiallyValid = legacyStep.partiallyValid === true;
const legacyMigrationBlocksWrite = legacyStep.blocksWrite === true;
@@ -257,7 +285,9 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}
const { findDoctorLegacyConfigIssues } =
await import("./doctor/shared/legacy-config-issues.js");
return findDoctorLegacyConfigIssues(snapshot.parsed, snapshot.parsed);
return runWithCurrentPluginMetadata(state.candidate, () =>
findDoctorLegacyConfigIssues(snapshot.parsed, snapshot.parsed),
);
})();
const seenLegacyIssues = new Set(
snapshot.legacyIssues.map((issue) => `${issue.path}:${issue.message}`),
@@ -310,10 +340,12 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
// Parsed config supplies invalid-key evidence only; migrations still mutate the
// include/env-resolved candidate so doctor never writes unresolved source values.
const normalized = normalizeCompatibilityConfigValues(state.candidate, {
blockedModelIdentities: blockedCodexModelIdentities,
sourceRaw: snapshot.parsed,
});
const normalized = runWithCurrentPluginMetadata(state.candidate, () =>
normalizeCompatibilityConfigValues(state.candidate, {
blockedModelIdentities: blockedCodexModelIdentities,
sourceRaw: snapshot.parsed,
}),
);
applyConfigMutation(normalized, {
fixHint: `Run "${doctorFixCommand}" to apply these changes.`,
});
@@ -343,14 +375,24 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const pluginActivationSourceConfig = state.candidate;
const { applyPluginAutoEnable } = await import("../config/plugin-auto-enable.js");
applyConfigMutation(applyPluginAutoEnable({ config: state.candidate, env: process.env }), {
fixHint: `Run "${doctorFixCommand}" to apply these changes.`,
});
applyConfigMutation(
runWithCurrentPluginMetadata(state.candidate, () =>
applyPluginAutoEnable({
config: state.candidate,
env: process.env,
}),
),
{
fixHint: `Run "${doctorFixCommand}" to apply these changes.`,
},
);
if (!shouldRepair) {
const { repairStaleAgentModelRefs } =
await import("./doctor/shared/stale-agent-model-ref-repair.js");
const staleAgentModelRepair = repairStaleAgentModelRefs(state.candidate, { env: process.env });
const staleAgentModelRepair = runWithCurrentPluginMetadata(state.candidate, () =>
repairStaleAgentModelRefs(state.candidate, { env: process.env }),
);
applyConfigMutation(staleAgentModelRepair, {
fixHint: `Run "${doctorFixCommand}" to remove stale agent model references.`,
sanitize: true,
@@ -360,10 +402,12 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const { collectPluginToolAllowlistWarnings } =
await import("./doctor/shared/plugin-tool-allowlist-warnings.js");
const pluginToolAllowlistWarnings = collectPluginToolAllowlistWarnings({
cfg: state.candidate,
env: process.env,
});
const pluginToolAllowlistWarnings = runWithCurrentPluginMetadata(state.candidate, () =>
collectPluginToolAllowlistWarnings({
cfg: state.candidate,
env: process.env,
}),
);
if (pluginToolAllowlistWarnings.length > 0) {
note(sanitizeDoctorNote(pluginToolAllowlistWarnings.join("\n")), "Doctor warnings");
}
@@ -375,21 +419,25 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
if (hasConfiguredChannels) {
const channelDoctor = await import("./doctor/shared/channel-doctor.js");
collectMutableAllowlistWarnings = channelDoctor.collectChannelDoctorMutableAllowlistWarnings;
const channelDoctorSequence = await channelDoctor.runChannelDoctorConfigSequences({
cfg: state.candidate,
env: process.env,
shouldRepair,
});
const channelDoctorSequence = await runWithCurrentPluginMetadata(state.candidate, () =>
channelDoctor.runChannelDoctorConfigSequences({
cfg: state.candidate,
env: process.env,
shouldRepair,
}),
);
emitDoctorNotes({
note,
changeNotes: channelDoctorSequence.changeNotes,
warningNotes: channelDoctorSequence.warningNotes,
});
for (const staleCleanup of await channelDoctor.collectChannelDoctorStaleConfigMutations(
state.candidate,
{ env: process.env },
)) {
const staleChannelCleanups = await runWithCurrentPluginMetadata(state.candidate, () =>
channelDoctor.collectChannelDoctorStaleConfigMutations(state.candidate, {
env: process.env,
}),
);
for (const staleCleanup of staleChannelCleanups) {
applyConfigMutation(staleCleanup, {
fixHint: `Run "${doctorFixCommand}" to remove stale channel plugin references.`,
sanitize: true,
@@ -410,8 +458,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
doctorFixCommand,
env: process.env,
blockedCodexProviderPlan,
pluginMetadataSnapshotState,
runWithPluginMetadataSnapshot,
});
state = repairSequence.state;
pluginMetadataSnapshotState.current = repairSequence.pluginMetadataSnapshot;
openAICodexAuthProfileIdMap = repairSequence.openAICodexAuthProfileIdMap;
if (repairSequence.authProfilesRepaired) {
await refreshGatewayAuthStateAfterAuthProfileRepair();
@@ -423,14 +474,17 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
});
} else {
const { collectDoctorPreviewNotes } = await import("./doctor/shared/preview-warnings.js");
const previewNotes = await collectDoctorPreviewNotes({
cfg: state.candidate,
activationSourceConfig: pluginActivationSourceConfig,
doctorFixCommand,
env: process.env,
allowExec: params.options.allowExec === true,
blockedCodexProviderPlan,
});
const collectPreviewNotes = async () =>
await collectDoctorPreviewNotes({
cfg: state.candidate,
activationSourceConfig: pluginActivationSourceConfig,
doctorFixCommand,
env: process.env,
allowExec: params.options.allowExec === true,
blockedCodexProviderPlan,
runWithPluginMetadataSnapshot,
});
const previewNotes = await runWithCurrentPluginMetadata(state.candidate, collectPreviewNotes);
emitDoctorNotes({
note,
infoNotes: previewNotes.infoNotes,
@@ -439,10 +493,12 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}
const mutableAllowlistWarnings = collectMutableAllowlistWarnings
? await collectMutableAllowlistWarnings({
cfg: state.candidate,
env: process.env,
})
? await runWithCurrentPluginMetadata(state.candidate, () =>
collectMutableAllowlistWarnings({
cfg: state.candidate,
env: process.env,
}),
)
: [];
if (mutableAllowlistWarnings.length > 0) {
note(sanitizeDoctorNote(mutableAllowlistWarnings.join("\n")), "Doctor warnings");
@@ -490,13 +546,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
: undefined,
cfg.models?.providers?.["opencode-go"] ? "opencode-go" : undefined,
].filter((pluginId): pluginId is string => pluginId !== undefined);
const activeOpencodePluginIds =
configuredOpencodePluginIds.length > 0
? (await import("../plugins/providers.js")).resolveEnabledProviderPluginIds({
config: cfg,
onlyPluginIds: configuredOpencodePluginIds,
})
: [];
let activeOpencodePluginIds: string[] = [];
if (configuredOpencodePluginIds.length > 0) {
const { resolveEnabledProviderPluginIds } = await import("../plugins/providers.js");
activeOpencodePluginIds = runWithCurrentPluginMetadata(cfg, () =>
resolveEnabledProviderPluginIds({
config: cfg,
onlyPluginIds: configuredOpencodePluginIds,
}),
);
}
noteOpencodeProviderOverrides(cfg, {
opencodePluginActive: activeOpencodePluginIds.includes("opencode"),
opencodeGoPluginActive: activeOpencodePluginIds.includes("opencode-go"),
@@ -525,5 +584,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
? { blockedCodexModelIdentities: blockedCodexProviderPlan.blockedModelIdentities }
: {}),
...(openAICodexAuthProfileIdMap?.size ? { openAICodexAuthProfileIdMap } : {}),
...(pluginMetadataSnapshotState.current
? { pluginMetadataSnapshot: pluginMetadataSnapshotState.current }
: {}),
runWithPluginMetadataSnapshot,
invalidatePluginMetadataSnapshot,
};
}
@@ -1,7 +1,14 @@
import {
readConfigFileSnapshot,
readConfigFileSnapshotWithPluginMetadata,
type ConfigSnapshotReadMeasure,
} from "../config/io.js";
import type { ConfigFileSnapshot } from "../config/types.js";
import type { StartupMigrationLease } from "../infra/startup-migration-checkpoint.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { addDoctorLegacyIssues } from "./doctor/shared/legacy-config-issues.js";
import { completeDoctorPluginMetadataSnapshot } from "./doctor/shared/plugin-metadata-snapshot-scope.js";
const loadInstalledPluginIndexStore = createLazyRuntimeModule(
() => import("../plugins/installed-plugin-index-store.js"),
@@ -21,6 +28,44 @@ function throwPluginRegistryPersistenceFailed(reason: string): never {
);
}
export async function readDoctorConfigPreflightSnapshot(params: {
allowCurrentPluginMetadata: boolean;
includePluginMetadata: boolean;
measure?: ConfigSnapshotReadMeasure;
observe?: boolean;
preparePluginMetadataSnapshot: boolean;
skipPluginValidation: boolean;
}): Promise<DoctorConfigPreflightPluginSnapshotRead> {
const sharedOptions = {
...(params.observe === false ? { observe: false } : {}),
...(params.measure ? { measure: params.measure } : {}),
...(params.allowCurrentPluginMetadata ? {} : { allowCurrentPluginMetadata: false }),
};
if (params.includePluginMetadata && !params.skipPluginValidation) {
const result = await readConfigFileSnapshotWithPluginMetadata(sharedOptions);
const pluginMetadataSnapshot = params.preparePluginMetadataSnapshot
? completeDoctorPluginMetadataSnapshot({
snapshot: result.pluginMetadataSnapshot,
config: result.snapshot.sourceConfig ?? result.snapshot.config ?? {},
})
: result.pluginMetadataSnapshot;
return {
snapshot: addDoctorLegacyIssues(result.snapshot, pluginMetadataSnapshot),
pluginMigrationFingerprint: pluginMetadataSnapshot?.configFingerprint?.trim() || null,
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
};
}
return {
snapshot: addDoctorLegacyIssues(
await readConfigFileSnapshot({
...sharedOptions,
skipPluginValidation: params.skipPluginValidation,
}),
),
pluginMigrationFingerprint: null,
};
}
export function needsRefreshedPluginIndexPersistence(
snapshotRead: DoctorConfigPreflightPluginSnapshotRead,
): boolean {
@@ -394,4 +394,366 @@ describe("gateway startup-migration refusal", () => {
await fs.promises.rm(root, { recursive: true, force: true });
}
}, 60_000);
it("reloads tool ownership after updater-managed manifest repair", async () => {
const root = await fs.promises.realpath(tempDirs.make("openclaw-updater-manifest-repair-"));
const stateDir = path.join(root, "state");
const configPath = path.join(root, "openclaw.json");
const pluginId = "updater-tool-owner";
const pluginDir = path.join(root, "plugins", pluginId);
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const config = {
gateway: { mode: "local", auth: { mode: "none" } },
plugins: {
load: { paths: [pluginDir] },
entries: { [pluginId]: { enabled: true } },
},
} satisfies OpenClawConfig;
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: root,
USERPROFILE: root,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_TEST_FAST: "1",
OPENCLAW_UPDATE_IN_PROGRESS: "1",
NO_COLOR: "1",
};
delete env.NODE_ENV;
delete env.OPENCLAW_HOME;
delete env.VITEST;
delete env.VITEST_POOL_ID;
delete env.VITEST_WORKER_ID;
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(config));
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: `@openclaw/${pluginId}`,
version: "1.0.0",
openclaw: { extensions: ["./index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export default {};\n");
fs.writeFileSync(
manifestPath,
JSON.stringify({
id: pluginId,
tools: ["updater_tool"],
configSchema: { type: "object" },
}),
);
const configFlowUrl = new URL("./doctor-config-flow.ts", import.meta.url).href;
const currentSnapshotUrl = new URL(
"../plugins/current-plugin-metadata-snapshot.ts",
import.meta.url,
).href;
const healthRunnersUrl = new URL(
"../flows/doctor-health-contribution-runners.state.ts",
import.meta.url,
).href;
const prompterUrl = new URL("./doctor-prompter.ts", import.meta.url).href;
const result = runIsolatedModuleScript(
env,
`
const fs = await import("node:fs");
const { loadAndMaybeMigrateDoctorConfig } = await import(${JSON.stringify(configFlowUrl)});
const { getCurrentPluginMetadataSnapshot } =
await import(${JSON.stringify(currentSnapshotUrl)});
const { runLegacyPluginManifestHealth } = await import(${JSON.stringify(healthRunnersUrl)});
const { createDoctorPrompter } = await import(${JSON.stringify(prompterUrl)});
const options = { nonInteractive: true, repair: true };
const runtime = {
log: () => {},
warn: () => {},
error: () => {},
exit: (code) => { throw new Error("doctor exited " + code); },
};
const prompter = createDoctorPrompter({ runtime, options });
const configResult = await loadAndMaybeMigrateDoctorConfig({
options,
confirm: async () => false,
runtime,
prompter,
});
const readToolOwners = () =>
configResult.runWithPluginMetadataSnapshot(
{ config: configResult.cfg },
() => [
...(getCurrentPluginMetadataSnapshot({ config: configResult.cfg })
?.owners.contracts.get("tools") ?? []),
],
);
const before = readToolOwners();
await runLegacyPluginManifestHealth({
cfg: configResult.cfg,
runtime,
prompter,
invalidatePluginMetadataSnapshot: configResult.invalidatePluginMetadataSnapshot,
});
const after = readToolOwners();
const manifest = JSON.parse(fs.readFileSync(${JSON.stringify(manifestPath)}, "utf8"));
console.log("__RESULT__" + JSON.stringify({
retainedBaseSnapshot: configResult.pluginMetadataSnapshot !== undefined,
before,
after,
legacyTools: manifest.tools,
contractTools: manifest.contracts?.tools,
}));
`,
{ timeoutMs: 60_000 },
);
expect(result.error, `${result.stderr}\n${result.stdout}`).toBeUndefined();
expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(0);
expect(result.signal, `${result.stderr}\n${result.stdout}`).toBeNull();
const resultLine = result.stdout.split("\n").find((line) => line.startsWith("__RESULT__"));
expect(resultLine, `${result.stderr}\n${result.stdout}`).toBeDefined();
expect(JSON.parse(resultLine!.slice("__RESULT__".length))).toEqual({
retainedBaseSnapshot: false,
before: [],
after: [pluginId],
contractTools: ["updater_tool"],
});
}, 90_000);
it("keeps full Doctor plugin metadata scans bounded and complete", async () => {
const runDoctorConfigFlow = async (
pluginCount: number,
agentCount: number,
mode: "preview" | "repair",
options: { configuredChannel?: boolean } = {},
): Promise<{
mode: "preview" | "repair";
configuredChannel: boolean;
configFlowScanCount: number;
doctorScanCount: number;
manifestPluginCount: number;
scoped: boolean;
}> => {
const root = await fs.promises.realpath(
tempDirs.make(
`openclaw-doctor-metadata-scans-${mode}-${pluginCount}-${agentCount}-${options.configuredChannel ? "channel" : "base"}-`,
),
);
const stateDir = path.join(root, "state");
const configPath = path.join(root, "openclaw.json");
const resultPath = path.join(root, "result.json");
const timelinePath = path.join(root, "timeline.jsonl");
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: root,
USERPROFILE: root,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "bundled"),
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DIAGNOSTICS: "1",
OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_TEST_FAST: "1",
NO_COLOR: "1",
};
delete env.NODE_ENV;
delete env.OPENCLAW_HOME;
delete env.VITEST;
delete env.VITEST_POOL_ID;
delete env.VITEST_WORKER_ID;
fs.mkdirSync(stateDir, { recursive: true });
const agentEntries = Object.fromEntries(
Array.from({ length: agentCount }, (_, index) => [
`doctor-agent-${index}`,
index === 0 ? { default: true } : {},
]),
);
const defaultAgentId = "doctor-agent-0";
const configuredChannelId = "doctor-scan-channel";
fs.writeFileSync(
configPath,
JSON.stringify({
agents: {
defaults: {
heartbeat: { agentId: defaultAgentId },
systemAgent: { agentId: defaultAgentId },
},
entries: agentEntries,
},
...(options.configuredChannel
? {
channels: { [configuredChannelId]: { enabled: true } },
plugins: { entries: { "doctor-scan-0": { enabled: true } } },
}
: {}),
gateway: { mode: "local", auth: { mode: "none" } },
talk: { agentId: defaultAgentId },
}),
);
for (let index = 0; index < pluginCount; index += 1) {
const pluginId = `doctor-scan-${index}`;
const pluginDir = writeManagedNpmPlugin({
stateDir,
packageName: `@openclaw/${pluginId}`,
pluginId,
version: "1.0.0",
});
if (options.configuredChannel && index === 0) {
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<
string,
unknown
>;
fs.writeFileSync(
manifestPath,
JSON.stringify({
...manifest,
channels: [configuredChannelId],
channelConfigs: {
[configuredChannelId]: { schema: { type: "object" } },
},
}),
"utf8",
);
}
fs.writeFileSync(
path.join(pluginDir, "doctor-contract-api.cjs"),
"module.exports = { resolveSessionStoreAgentIds: () => [] };\n",
"utf8",
);
}
closeOpenClawStateDatabaseForTest();
const configFlowUrl = new URL("./doctor-config-flow.ts", import.meta.url).href;
const doctorHealthUrl = new URL("../flows/doctor-health.ts", import.meta.url).href;
const doctorOptions = {
nonInteractive: true,
...(mode === "repair" ? { repair: true } : {}),
};
const result = runIsolatedModuleScript(
env,
`
const { loadAndMaybeMigrateDoctorConfig } = await import(${JSON.stringify(configFlowUrl)});
const result = await loadAndMaybeMigrateDoctorConfig({
options: ${JSON.stringify(doctorOptions)},
confirm: async () => false,
});
const metadata = result.pluginMetadataSnapshot;
const fs = await import("node:fs");
const countMetadataScans = () => fs.readFileSync(${JSON.stringify(timelinePath)}, "utf8")
.trim()
.split("\\n")
.map((line) => JSON.parse(line))
.filter((event) => event.type === "span.end" && event.name === "plugins.metadata.scan")
.length;
const configFlowScanCount = countMetadataScans();
fs.writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({
mode: ${JSON.stringify(mode)},
configuredChannel: ${JSON.stringify(options.configuredChannel === true)},
configFlowScanCount,
manifestPluginCount: metadata?.plugins.length ?? -1,
scoped: metadata?.pluginIds !== undefined,
}));
const { doctorCommand } = await import(${JSON.stringify(doctorHealthUrl)});
await doctorCommand({
log: () => {},
error: () => {},
exit: (code) => { throw new Error("doctor exited " + code); },
}, ${JSON.stringify(doctorOptions)});
const output = JSON.parse(fs.readFileSync(${JSON.stringify(resultPath)}, "utf8"));
fs.writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({
...output,
doctorScanCount: countMetadataScans() - configFlowScanCount,
}));
`,
{ timeoutMs: 60_000 },
);
expect(result.error, `${result.stderr}\n${result.stdout}`).toBeUndefined();
expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(0);
expect(result.signal, `${result.stderr}\n${result.stdout}`).toBeNull();
const metadata = JSON.parse(fs.readFileSync(resultPath, "utf8")) as {
mode: "preview" | "repair";
configuredChannel: boolean;
configFlowScanCount: number;
doctorScanCount: number;
manifestPluginCount: number;
scoped: boolean;
};
return metadata;
};
const repairBaseline = await runDoctorConfigFlow(1, 1, "repair");
const repairManyPlugins = await runDoctorConfigFlow(12, 1, "repair");
const repairManyAgents = await runDoctorConfigFlow(1, 12, "repair");
const repairConfiguredChannel = await runDoctorConfigFlow(1, 1, "repair", {
configuredChannel: true,
});
const previewBaseline = await runDoctorConfigFlow(1, 1, "preview");
const previewManyPlugins = await runDoctorConfigFlow(12, 1, "preview");
const previewManyAgents = await runDoctorConfigFlow(1, 12, "preview");
const previewConfiguredChannel = await runDoctorConfigFlow(1, 1, "preview", {
configuredChannel: true,
});
const expectBoundedScans = (params: {
baseline: typeof repairBaseline;
manyPlugins: typeof repairManyPlugins;
manyAgents: typeof repairManyAgents;
}) => {
expect(params.baseline).toMatchObject({ manifestPluginCount: 1, scoped: false });
expect(params.manyPlugins).toMatchObject({ manifestPluginCount: 12, scoped: false });
expect(params.manyAgents).toMatchObject({ manifestPluginCount: 1, scoped: false });
expect(params.baseline.configFlowScanCount).toBeGreaterThan(0);
expect(params.baseline.configFlowScanCount).toBeLessThanOrEqual(12);
expect(params.manyPlugins.configFlowScanCount).toBe(params.baseline.configFlowScanCount);
expect(params.manyAgents.configFlowScanCount).toBe(
params.baseline.configFlowScanCount + (params.baseline.mode === "preview" ? 11 : 0),
);
expect(params.baseline.doctorScanCount).toBeLessThanOrEqual(20);
expect(params.manyPlugins.doctorScanCount).toBe(params.baseline.doctorScanCount);
expect(params.manyAgents.doctorScanCount).toBe(params.baseline.doctorScanCount + 11);
};
const expectConfiguredChannelScans = (params: {
baseline: typeof repairBaseline;
configuredChannel: typeof repairConfiguredChannel;
}) => {
expect(params.configuredChannel).toMatchObject({
configuredChannel: true,
manifestPluginCount: 1,
scoped: false,
});
expect(params.configuredChannel.configFlowScanCount).toBeGreaterThanOrEqual(
params.baseline.configFlowScanCount,
);
expect(params.configuredChannel.configFlowScanCount).toBeLessThanOrEqual(
params.baseline.configFlowScanCount + (params.baseline.mode === "preview" ? 3 : 0),
);
expect(params.configuredChannel.doctorScanCount).toBeGreaterThanOrEqual(
params.baseline.doctorScanCount,
);
expect(params.configuredChannel.doctorScanCount).toBeLessThanOrEqual(
params.baseline.doctorScanCount + (params.baseline.mode === "preview" ? 3 : 2),
);
};
expectBoundedScans({
baseline: repairBaseline,
manyPlugins: repairManyPlugins,
manyAgents: repairManyAgents,
});
expectBoundedScans({
baseline: previewBaseline,
manyPlugins: previewManyPlugins,
manyAgents: previewManyAgents,
});
expectConfiguredChannelScans({
baseline: repairBaseline,
configuredChannel: repairConfiguredChannel,
});
expectConfiguredChannelScans({
baseline: previewBaseline,
configuredChannel: previewConfiguredChannel,
});
}, 300_000);
});
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LegacyConfigIssue } from "../config/types.js";
import type { ConfigFileSnapshot, LegacyConfigIssue } from "../config/types.js";
import type { StateMigrationResult } from "./doctor-config-preflight.state-migration.test-helpers.js";
const autoMigrateLegacyStateDir = vi.hoisted(() =>
@@ -63,7 +63,20 @@ const readConfigFileSnapshot = vi.hoisted(() =>
issues: [] as Array<{ path: string; message: string }>,
})),
);
const findDoctorLegacyConfigIssues = vi.hoisted(() => vi.fn((): LegacyConfigIssue[] => []));
const findDoctorLegacyConfigIssues = vi.hoisted(() =>
vi.fn((_raw: unknown, _sourceRaw?: unknown): LegacyConfigIssue[] => []),
);
const addDoctorLegacyIssues = vi.hoisted(() =>
vi.fn((snapshot: ConfigFileSnapshot): ConfigFileSnapshot => {
if (!snapshot.exists) {
return snapshot;
}
const resolvedRaw = snapshot.sourceConfig ?? snapshot.config ?? {};
const sourceRaw = snapshot.parsed ?? resolvedRaw;
const legacyIssues = findDoctorLegacyConfigIssues(resolvedRaw, sourceRaw);
return legacyIssues.length === 0 ? snapshot : { ...snapshot, legacyIssues };
}),
);
const note = vi.hoisted(() => vi.fn());
vi.mock("./doctor-state-migrations.js", () => ({
@@ -87,6 +100,7 @@ vi.mock("../config/io.js", () => ({
}));
vi.mock("./doctor/shared/legacy-config-issues.js", () => ({
addDoctorLegacyIssues,
findDoctorLegacyConfigIssues,
}));
@@ -143,6 +143,10 @@ const readConfigFileSnapshotWithPluginMetadata = vi.hoisted(() =>
})),
);
const findDoctorLegacyConfigIssues = vi.hoisted(() => vi.fn((): LegacyConfigIssue[] => []));
const addDoctorLegacyIssues = vi.hoisted(() => vi.fn(<T>(snapshot: T): T => snapshot));
const runWithPluginMetadataSnapshot = vi.hoisted(() =>
vi.fn((_scope: unknown, run: () => unknown) => run()),
);
const note = vi.hoisted(() => vi.fn());
function queueConfigSnapshot(
@@ -215,9 +219,17 @@ vi.mock("../config/io.js", () => ({
}));
vi.mock("./doctor/shared/legacy-config-issues.js", () => ({
addDoctorLegacyIssues,
findDoctorLegacyConfigIssues,
}));
vi.mock("./doctor/shared/plugin-metadata-snapshot-scope.js", () => ({
createDoctorPluginMetadataSnapshotScope: () => ({
run: runWithPluginMetadataSnapshot,
invalidate: vi.fn(),
}),
}));
vi.mock("../../packages/terminal-core/src/note.js", () => ({ note }));
const { runDoctorConfigPreflight } = await import("./doctor-config-preflight.js");
+54 -72
View File
@@ -7,7 +7,6 @@ import {
parseConfigJson5,
preserveConfigSnapshotAsClobbered,
readConfigFileSnapshot,
readConfigFileSnapshotWithPluginMetadata,
recoverConfigFromJsonRootSuffix,
recoverConfigFromLastKnownGood,
} from "../config/io.js";
@@ -15,13 +14,14 @@ import type { ConfigSnapshotReadMeasure } from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { resolveCanonicalConfigPath } from "../config/paths.js";
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
import type { ConfigFileSnapshot, LegacyConfigIssue } from "../config/types.js";
import type { ConfigFileSnapshot } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue } from "../infra/env.js";
import type {
MigrationCheckpointIdentity,
StartupMigrationLease,
} from "../infra/startup-migration-checkpoint.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js";
import { ExitError } from "../runtime.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
@@ -31,6 +31,7 @@ import { measureDoctorConfigPreflightStep } from "./doctor-config-preflight-meas
import {
needsRefreshedPluginIndexPersistence,
persistRefreshedPluginIndex,
readDoctorConfigPreflightSnapshot,
type DoctorConfigPreflightPluginSnapshotRead,
} from "./doctor-config-preflight-plugin-index.js";
import {
@@ -39,8 +40,8 @@ import {
runStartupUpgradeConvergence,
} from "./doctor-config-preflight-plugin-verification.js";
import type { CronCodexRuntimePolicyTarget } from "./doctor/cron/store-migration.js";
import { findDoctorLegacyConfigIssues } from "./doctor/shared/legacy-config-issues.js";
import { resolveStateMigrationConfigInput } from "./doctor/shared/legacy-config-state-migration-input.js";
import { createDoctorPluginMetadataSnapshotScope } from "./doctor/shared/plugin-metadata-snapshot-scope.js";
const loadDoctorStateMigrations = createLazyRuntimeModule(
() => import("./doctor-state-migrations.js"),
@@ -113,30 +114,10 @@ async function maybeMigrateLegacyConfig(): Promise<string[]> {
export type DoctorConfigPreflightResult = {
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
baseConfig: OpenClawConfig;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
cronCodexRuntimePolicyTargets?: CronCodexRuntimePolicyTarget[];
};
function collectDoctorLegacyIssues(
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
): LegacyConfigIssue[] {
if (!snapshot.exists) {
return [];
}
const resolvedRaw = snapshot.sourceConfig ?? snapshot.config ?? {};
const sourceRaw = snapshot.parsed ?? resolvedRaw;
return findDoctorLegacyConfigIssues(resolvedRaw, sourceRaw);
}
function addDoctorLegacyIssues(
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
): Awaited<ReturnType<typeof readConfigFileSnapshot>> {
const legacyIssues = collectDoctorLegacyIssues(snapshot);
if (legacyIssues.length === 0) {
return snapshot;
}
return { ...snapshot, legacyIssues };
}
/** Returns true during updater-managed config rewrites where plugin validation may be stale. */
export function shouldSkipPluginValidationForDoctorConfigPreflight(
env: NodeJS.ProcessEnv = process.env,
@@ -244,6 +225,8 @@ export async function runDoctorConfigPreflight(
beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise<boolean>;
requireStateMigrationCheckpoint?: boolean;
requireStartupMigrationCheckpoint?: boolean;
/** Load one authoritative plugin metadata snapshot for the caller's full lifecycle. */
preparePluginMetadataSnapshot?: boolean;
/** Core state was proven absent before Gateway selection could create runtime files. */
skipPristineCoreStateMigrations?: boolean;
/** Prepared before Gateway bootstrap can create files under an otherwise pristine state root. */
@@ -281,6 +264,10 @@ export async function runDoctorConfigPreflight(
let doctorMediaPersistenceAttempted = false;
let legacyConfigMigrationComplete = false;
let configSnapshotRead: DoctorConfigPreflightPluginSnapshotRead | undefined;
const { run: runWithPluginMetadataSnapshot } = createDoctorPluginMetadataSnapshotScope({
getBaseSnapshot: () => configSnapshotRead?.pluginMetadataSnapshot,
env: process.env,
});
const ensureStartupMigrationLease = () => {
if (startupMigrationLease || !migrationCheckpoint) {
return;
@@ -322,33 +309,17 @@ export async function runDoctorConfigPreflight(
}
};
const readConfigSnapshotForPreflight = async (allowCurrentPluginMetadata = true) =>
await measurePreflightStep("config-snapshot", async () => {
const sharedOptions = {
...(options.observe === false ? { observe: false } : {}),
...(options.measure ? { measure: options.measure } : {}),
...(allowCurrentPluginMetadata ? {} : { allowCurrentPluginMetadata: false }),
};
if (migrationCheckpoint && !shouldSkipPluginValidationForDoctorConfigPreflight()) {
const result = await readConfigFileSnapshotWithPluginMetadata(sharedOptions);
return {
snapshot: addDoctorLegacyIssues(result.snapshot),
pluginMigrationFingerprint:
result.pluginMetadataSnapshot?.configFingerprint?.trim() || null,
...(result.pluginMetadataSnapshot
? { pluginMetadataSnapshot: result.pluginMetadataSnapshot }
: {}),
};
}
return {
snapshot: addDoctorLegacyIssues(
await readConfigFileSnapshot({
...sharedOptions,
skipPluginValidation: shouldSkipPluginValidationForDoctorConfigPreflight(),
}),
),
pluginMigrationFingerprint: null,
};
});
await measurePreflightStep("config-snapshot", () =>
readDoctorConfigPreflightSnapshot({
allowCurrentPluginMetadata,
includePluginMetadata:
Boolean(migrationCheckpoint) || options.preparePluginMetadataSnapshot === true,
measure: options.measure,
observe: options.observe,
preparePluginMetadataSnapshot: options.preparePluginMetadataSnapshot === true,
skipPluginValidation: shouldSkipPluginValidationForDoctorConfigPreflight(),
}),
);
try {
if (migrationCheckpoint && !skipPristineStartupStateMigrations) {
// Capture pristine state before command bootstrap can prepare runtime state.
@@ -524,13 +495,15 @@ export async function runDoctorConfigPreflight(
// Keep their doctor owner active without loading channel/session detectors.
noteStartupStateMigrationResult(
await measurePreflightStep("plugin-doctor-migrations", () =>
autoMigrateLegacyPluginDoctorState({
config: pluginDoctorOnlyConfig,
env: process.env,
...(options.doctorOnlyStateMigrations === true
? { doctorOnlyStateMigrations: true }
: {}),
}),
runWithPluginMetadataSnapshot({ config: pluginDoctorOnlyConfig }, () =>
autoMigrateLegacyPluginDoctorState({
config: pluginDoctorOnlyConfig,
env: process.env,
...(options.doctorOnlyStateMigrations === true
? { doctorOnlyStateMigrations: true }
: {}),
}),
),
),
);
} else if (stateMigrationInput.cfg) {
@@ -557,13 +530,15 @@ export async function runDoctorConfigPreflight(
noteStartupStateMigrationResult({ changes: [], warnings: cronCodexPlan.warnings });
}
const legacyStateResult = await measurePreflightStep("legacy-state-migrations", () =>
autoMigrateLegacyState({
cfg: migrationConfig,
...(pluginDoctorConfig ? { pluginDoctorConfig } : {}),
env: process.env,
recoverCorruptTargetStore: options.recoverCorruptTargetStore,
doctorOnlyStateMigrations: options.doctorOnlyStateMigrations,
}),
runWithPluginMetadataSnapshot({ config: pluginDoctorConfig ?? migrationConfig }, () =>
autoMigrateLegacyState({
cfg: migrationConfig,
...(pluginDoctorConfig ? { pluginDoctorConfig } : {}),
env: process.env,
recoverCorruptTargetStore: options.recoverCorruptTargetStore,
doctorOnlyStateMigrations: options.doctorOnlyStateMigrations,
}),
),
);
doctorMediaPersistenceAttempted = options.doctorOnlyStateMigrations === true;
noteStartupStateMigrationResult(legacyStateResult);
@@ -571,13 +546,15 @@ export async function runDoctorConfigPreflight(
const pluginDoctorConfig = stateMigrationInput.pluginDoctorConfig;
noteStartupStateMigrationResult(
await measurePreflightStep("plugin-doctor-migrations", () =>
autoMigrateLegacyPluginDoctorState({
config: pluginDoctorConfig,
env: process.env,
...(options.doctorOnlyStateMigrations === true
? { doctorOnlyStateMigrations: true }
: {}),
}),
runWithPluginMetadataSnapshot({ config: pluginDoctorConfig }, () =>
autoMigrateLegacyPluginDoctorState({
config: pluginDoctorConfig,
env: process.env,
...(options.doctorOnlyStateMigrations === true
? { doctorOnlyStateMigrations: true }
: {}),
}),
),
),
);
noteStartupStateMigrationResult(
@@ -645,6 +622,8 @@ export async function runDoctorConfigPreflight(
'OpenClaw config identity changed while persisting the refreshed plugin registry; refusing to write the migration checkpoint. Run "openclaw doctor --fix" and retry.',
);
}
// The persisted reread is the only inventory mutation in preflight. Replace both the
// authoritative snapshot and every fact derived from it at that boundary.
configSnapshotRead = persistedSnapshotRead;
migrationCheckpointIdentity = persistedIdentity;
}
@@ -744,6 +723,9 @@ export async function runDoctorConfigPreflight(
return {
snapshot,
baseConfig,
...(configSnapshotRead.pluginMetadataSnapshot
? { pluginMetadataSnapshot: configSnapshotRead.pluginMetadataSnapshot }
: {}),
...(cronCodexRuntimePolicyTargets.length > 0 ? { cronCodexRuntimePolicyTargets } : {}),
};
} finally {
@@ -4,6 +4,10 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
createPluginMetadataSnapshot,
makeRegistry,
} from "../config/plugin-auto-enable.test-helpers.js";
import { validateConfigObject } from "../config/validation.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
@@ -975,6 +979,41 @@ describe("normalizeCompatibilityConfigValues", () => {
expect(result.config.agents?.list?.[1]?.model).toBe("anthropic/claude-sonnet-4-6");
});
it("uses a retained metadata snapshot for plugin-owned providers", () => {
const config = {
agents: {
defaults: {
model: "my-cli/model",
},
},
} as OpenClawConfig;
const baseSnapshot = createPluginMetadataSnapshot({
config,
manifestRegistry: makeRegistry([
{
id: "my-cli-plugin",
channels: [],
providers: ["my-cli"],
},
]),
});
const pluginMetadataSnapshot = {
...baseSnapshot,
owners: {
...baseSnapshot.owners,
providers: new Map([["my-cli", ["my-cli-plugin"]]]),
},
};
const result = repairStaleAgentModelRefs(config, {
pluginMetadataSnapshot,
persistedProviderIdsByAgentId: new Map(),
});
expect(result.changes).toEqual([]);
expect(result.config.agents?.defaults?.model).toBe("my-cli/model");
});
it("preserves model refs backed by a configured installable provider", () => {
const result = repairStaleAgentModelRefs(
{
+2 -1
View File
@@ -220,7 +220,7 @@ describe("doctor plugin manifest legacy contract repair", () => {
configSchema: { type: "object" },
});
await maybeRepairLegacyPluginManifestContracts({
const changed = await maybeRepairLegacyPluginManifestContracts({
config: configWithPluginLoadPath(pluginsRoot),
env: {
...process.env,
@@ -230,6 +230,7 @@ describe("doctor plugin manifest legacy contract repair", () => {
prompter: createPrompter(),
note: vi.fn(),
});
expect(changed).toBe(true);
const next = JSON.parse(fs.readFileSync(path.join(root, "openclaw.plugin.json"), "utf-8")) as {
speechProviders?: string[];
+4 -3
View File
@@ -182,7 +182,7 @@ export async function maybeRepairLegacyPluginManifestContracts(params: {
runtime: RuntimeEnv;
prompter: DoctorPrompter;
note?: typeof note;
}): Promise<void> {
}): Promise<boolean> {
const migrations = collectLegacyPluginManifestContractMigrations({
...(params.config ? { config: params.config } : {}),
...(params.env ? { env: params.env } : {}),
@@ -190,7 +190,7 @@ export async function maybeRepairLegacyPluginManifestContracts(params: {
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
if (migrations.length === 0) {
return;
return false;
}
const emitNote = params.note ?? note;
@@ -209,7 +209,7 @@ export async function maybeRepairLegacyPluginManifestContracts(params: {
initialValue: true,
}));
if (!shouldRepair) {
return;
return false;
}
const applied: string[] = [];
@@ -227,4 +227,5 @@ export async function maybeRepairLegacyPluginManifestContracts(params: {
if (applied.length > 0) {
emitNote(applied.join("\n"), "Doctor changes");
}
return applied.length > 0;
}
+13 -3
View File
@@ -450,16 +450,17 @@ describe("maybeRepairPluginRegistryState", () => {
const pluginDir = path.join(stateDir, "plugins", "demo");
fs.mkdirSync(pluginDir, { recursive: true });
await writePersistedInstalledPluginIndex(createCurrentIndex(), { stateDir });
const candidate = createCandidate(pluginDir);
const nextConfig = await maybeRepairPluginRegistryState({
stateDir,
candidates: [createCandidate(pluginDir)],
candidates: [candidate],
env: hermeticEnv(),
config: {},
prompter: { shouldRepair: true },
});
expect(nextConfig).toStrictEqual({});
expect(nextConfig).toStrictEqual({ config: {}, pluginInventoryChanged: true });
const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir);
expect(persisted.refreshReason).toBe("migration");
expect(persisted.plugins).toStrictEqual([
@@ -469,6 +470,15 @@ describe("maybeRepairPluginRegistryState", () => {
origin: "global",
}),
]);
await expect(
maybeRepairPluginRegistryState({
stateDir,
candidates: [candidate],
env: hermeticEnv(),
config: {},
prompter: { shouldRepair: true },
}),
).resolves.toStrictEqual({ config: {} });
});
it("warns about stale managed npm packages that shadow bundled plugins", async () => {
@@ -931,7 +941,7 @@ describe("maybeRepairPluginRegistryState", () => {
config: {},
prompter: { shouldRepair: false },
}),
).resolves.toEqual({});
).resolves.toEqual({ config: {} });
const notes = vi.mocked(note).mock.calls.join("\n");
expect(notes).toContain("Managed npm plugin packages could not be inspected");
+22 -4
View File
@@ -15,6 +15,7 @@ import {
} from "../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import { hasRetainedManagedNpmInstallMarker } from "../plugins/managed-npm-retention.js";
import { resolveInstalledManifestRegistryIndexFingerprint } from "../plugins/manifest-registry-installed.js";
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
import {
listStaleLocalBundledPluginInstallRecords,
@@ -47,6 +48,11 @@ type PluginRegistryDoctorRepairParams = Omit<PluginRegistryInstallMigrationParam
prompter: Pick<DoctorPrompter, "shouldRepair">;
};
type PluginRegistryDoctorRepairResult = {
config: OpenClawConfig;
pluginInventoryChanged?: true;
};
type StaleManagedNpmBundledPlugin = {
pluginId: string;
packageName: string;
@@ -577,7 +583,7 @@ function assertNeverPluginRegistryIssue(issue: never): never {
*/
export async function maybeRepairPluginRegistryState(
params: PluginRegistryDoctorRepairParams,
): Promise<OpenClawConfig> {
): Promise<PluginRegistryDoctorRepairResult> {
const preflight = preflightPluginRegistryInstallMigration(params);
const migrationParams = {
@@ -611,7 +617,7 @@ export async function maybeRepairPluginRegistryState(
"Plugin registry",
);
}
return params.config;
return { config: params.config };
}
if (preflight.action === "migrate") {
@@ -634,7 +640,10 @@ export async function maybeRepairPluginRegistryState(
"Plugin registry",
);
}
return params.config;
return {
config: params.config,
...(result.migrated ? { pluginInventoryChanged: true as const } : {}),
};
}
if (
@@ -662,7 +671,16 @@ export async function maybeRepairPluginRegistryState(
`Plugin registry refreshed: ${enabled}/${total} enabled plugins indexed.`,
"Plugin registry",
);
const indexChanged =
resolveInstalledManifestRegistryIndexFingerprint(preflight.current) !==
resolveInstalledManifestRegistryIndexFingerprint(index);
return {
config: params.config,
...(indexChanged || repairedPluginOpenClawHostLinks
? { pluginInventoryChanged: true as const }
: {}),
};
}
return params.config;
return { config: params.config };
}
+12 -5
View File
@@ -4,6 +4,7 @@ import { note } from "../../packages/terminal-core/src/note.js";
import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginMetadataSnapshotScopeRunner } from "../plugins/current-plugin-metadata-snapshot.js";
import type { SkillStatusEntry } from "../skills/discovery/status.js";
import { buildWorkspaceSkillStatus } from "../skills/discovery/status.js";
import {
@@ -100,6 +101,7 @@ function collectFleetUnavailableSkills(
export async function maybeRepairSkillReadiness(params: {
cfg: OpenClawConfig;
prompter: DoctorPrompter;
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
}): Promise<OpenClawConfig> {
const agentIds = listAgentIds(params.cfg);
const scopes = agentIds.map((agentId) => ({
@@ -107,11 +109,16 @@ export async function maybeRepairSkillReadiness(params: {
workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId),
}));
const reports = scopes.map(({ agentId, workspaceDir }) => {
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: params.cfg,
agentId,
});
return { agentId, report, unavailable: collectUnavailableAgentSkills(report) };
const buildReport = () => {
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: params.cfg,
agentId,
});
return { agentId, report, unavailable: collectUnavailableAgentSkills(report) };
};
return params.runWithPluginMetadataSnapshot
? params.runWithPluginMetadataSnapshot({ config: params.cfg, workspaceDir }, buildReport)
: buildReport();
});
const fleetUnavailable = collectFleetUnavailableSkills(
reports.map(({ report, unavailable: unavailableForAgent }) => ({
+66 -48
View File
@@ -8,6 +8,7 @@ import {
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HealthFinding } from "../flows/health-checks.js";
import type { PluginMetadataSnapshotScopeRunner } from "../plugins/current-plugin-metadata-snapshot.js";
import {
resolvePluginVersionDriftUpdateCommand,
type PluginVersionDriftReport,
@@ -21,6 +22,7 @@ import { listTaskFlowRecords } from "../tasks/task-flow-runtime-internal.js";
type NoteWorkspaceStatusOptions = {
pluginVersionDrift?: PluginVersionDriftReport;
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
};
const WORKSPACE_STATUS_CHECK_ID = "core/doctor/workspace-status";
@@ -150,21 +152,30 @@ export function collectWorkspaceStatusHealthFindings(
}));
const workspaceFindings: HealthFinding[] = [];
for (const { agentId, workspaceDir } of scopes) {
const prefix = agentIds.length > 1 ? `Agent "${agentId}": ` : "";
const pluginRegistry = buildPluginRegistrySnapshotReport({ config: cfg, workspaceDir });
const compatibilityWarnings = buildPluginCompatibilityWarnings({
config: cfg,
workspaceDir,
report: pluginRegistry,
});
for (const message of compatibilityWarnings) {
workspaceFindings.push(pluginCompatibilityWarningToHealthFinding(`${prefix}${message}`));
}
for (const diagnostic of pluginRegistry.diagnostics) {
workspaceFindings.push(
pluginDiagnosticToHealthFinding(diagnostic, `${prefix}${diagnostic.message}`),
);
}
const collectForWorkspace = () => {
const findings: HealthFinding[] = [];
const prefix = agentIds.length > 1 ? `Agent "${agentId}": ` : "";
const pluginRegistry = buildPluginRegistrySnapshotReport({ config: cfg, workspaceDir });
const compatibilityWarnings = buildPluginCompatibilityWarnings({
config: cfg,
workspaceDir,
report: pluginRegistry,
});
for (const message of compatibilityWarnings) {
findings.push(pluginCompatibilityWarningToHealthFinding(`${prefix}${message}`));
}
for (const diagnostic of pluginRegistry.diagnostics) {
findings.push(
pluginDiagnosticToHealthFinding(diagnostic, `${prefix}${diagnostic.message}`),
);
}
return findings;
};
workspaceFindings.push(
...(options.runWithPluginMetadataSnapshot
? options.runWithPluginMetadataSnapshot({ config: cfg, workspaceDir }, collectForWorkspace)
: collectForWorkspace()),
);
}
return [
@@ -210,40 +221,47 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig, options: NoteWorkspaceS
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
}));
for (const { agentId, workspaceDir } of scopes) {
const prefix = agentIds.length > 1 ? `Agent "${agentId}":\n` : "";
const pluginRegistry = buildPluginRegistrySnapshotReport({ config: cfg, workspaceDir });
const errored = pluginRegistry.plugins
.filter((plugin) => plugin.status === "error")
.toSorted((a, b) => a.id.localeCompare(b.id));
if (errored.length > 0) {
const lines = [
`${prefix}Errors: ${errored.length}`,
`- ${errored
.slice(0, 10)
.map((plugin) => plugin.id)
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`,
];
note(lines.join("\n"), "Plugins");
}
const compatibilityWarnings = buildPluginCompatibilityWarnings({
config: cfg,
workspaceDir,
report: pluginRegistry,
});
if (compatibilityWarnings.length > 0) {
note(
`${prefix}${compatibilityWarnings.map((line) => `- ${line}`).join("\n")}`,
"Plugin compatibility",
);
}
if (pluginRegistry.diagnostics.length > 0) {
const lines = pluginRegistry.diagnostics.map((diag) => {
const level = diag.level.toUpperCase();
const plugin = diag.pluginId ? ` ${diag.pluginId}` : "";
const source = diag.source ? ` (${diag.source})` : "";
return `- ${level}${plugin}: ${diag.message}${source}`;
const noteForWorkspace = () => {
const prefix = agentIds.length > 1 ? `Agent "${agentId}":\n` : "";
const pluginRegistry = buildPluginRegistrySnapshotReport({ config: cfg, workspaceDir });
const errored = pluginRegistry.plugins
.filter((plugin) => plugin.status === "error")
.toSorted((a, b) => a.id.localeCompare(b.id));
if (errored.length > 0) {
const lines = [
`${prefix}Errors: ${errored.length}`,
`- ${errored
.slice(0, 10)
.map((plugin) => plugin.id)
.join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}`,
];
note(lines.join("\n"), "Plugins");
}
const compatibilityWarnings = buildPluginCompatibilityWarnings({
config: cfg,
workspaceDir,
report: pluginRegistry,
});
note(`${prefix}${lines.join("\n")}`, "Plugin diagnostics");
if (compatibilityWarnings.length > 0) {
note(
`${prefix}${compatibilityWarnings.map((line) => `- ${line}`).join("\n")}`,
"Plugin compatibility",
);
}
if (pluginRegistry.diagnostics.length > 0) {
const lines = pluginRegistry.diagnostics.map((diag) => {
const level = diag.level.toUpperCase();
const plugin = diag.pluginId ? ` ${diag.pluginId}` : "";
const source = diag.source ? ` (${diag.source})` : "";
return `- ${level}${plugin}: ${diag.message}${source}`;
});
note(`${prefix}${lines.join("\n")}`, "Plugin diagnostics");
}
};
if (options.runWithPluginMetadataSnapshot) {
options.runWithPluginMetadataSnapshot({ config: cfg, workspaceDir }, noteForWorkspace);
} else {
noteForWorkspace();
}
}
notePluginVersionDrift(options.pluginVersionDrift);
+157 -1
View File
@@ -1,6 +1,7 @@
// Doctor repair sequencing tests cover ordered repair execution and dependency handling.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import { runDoctorRepairSequence } from "./repair-sequencing.js";
const mocks = vi.hoisted(() => ({
@@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({
getInstalledPluginRecord: vi.fn(),
isInstalledPluginEnabled: vi.fn(),
loadInstalledPluginIndex: vi.fn(),
loadPluginMetadataSnapshot: vi.fn(),
maybeRepairGroupAllowFromFallback: vi.fn(),
maybeRepairPluginOpenClawHostLinks: vi.fn(),
maybeRepairLegacyOAuthSidecarProfiles: vi.fn(),
@@ -28,6 +30,7 @@ const mocks = vi.hoisted(() => ({
repairMissingConfiguredPluginInstalls: vi.fn(),
repairStaleAgentModelRefs: vi.fn(),
resolveAuthProfileOrder: vi.fn(),
resolveProviderInstallCatalogEntries: vi.fn(),
resolveProfileUnusableUntilForDisplay: vi.fn(),
}));
@@ -87,6 +90,14 @@ vi.mock("../../plugins/installed-plugin-index.js", async (importOriginal) => ({
loadInstalledPluginIndex: mocks.loadInstalledPluginIndex,
}));
vi.mock("../../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: mocks.loadPluginMetadataSnapshot,
}));
vi.mock("../../plugins/provider-install-catalog.js", () => ({
resolveProviderInstallCatalogEntries: mocks.resolveProviderInstallCatalogEntries,
}));
vi.mock("./shared/channel-doctor.js", () => ({
collectChannelDoctorCompatibilityMutations: mocks.collectChannelDoctorCompatibilityMutations,
collectChannelDoctorRepairMutations: ({ cfg }: { cfg: OpenClawConfig }) => {
@@ -250,6 +261,9 @@ describe("doctor repair sequencing", () => {
mocks.getInstalledPluginRecord.mockReturnValue(undefined);
mocks.isInstalledPluginEnabled.mockReturnValue(false);
mocks.loadInstalledPluginIndex.mockReturnValue({ plugins: [] });
mocks.loadPluginMetadataSnapshot.mockReturnValue({
manifestRegistry: { plugins: [], diagnostics: [] },
});
mocks.maybeRepairGroupAllowFromFallback.mockImplementation((cfg: OpenClawConfig) => ({
config: cfg,
changes: [],
@@ -299,6 +313,7 @@ describe("doctor repair sequencing", () => {
});
mocks.collectChannelDoctorCompatibilityMutations.mockReturnValue([]);
mocks.resolveAuthProfileOrder.mockReturnValue([]);
mocks.resolveProviderInstallCatalogEntries.mockReturnValue([]);
mocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(null);
mocks.maybeRepairStalePluginConfig.mockImplementation((cfg: OpenClawConfig) => ({
config: cfg,
@@ -509,6 +524,10 @@ describe("doctor repair sequencing", () => {
it("repairs managed npm plugin drift before missing plugin install repair", async () => {
const events: string[] = [];
const refreshedSnapshot = {
manifestRegistry: { plugins: [], diagnostics: [] },
};
mocks.loadPluginMetadataSnapshot.mockReturnValueOnce(refreshedSnapshot);
mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation(() => {
events.push("bundled-shadow-cleanup");
return true;
@@ -522,7 +541,7 @@ describe("doctor repair sequencing", () => {
return { changes: [], warnings: [] };
});
await runDoctorRepairSequence({
const result = await runDoctorRepairSequence({
state: {
cfg: {
plugins: {
@@ -553,6 +572,8 @@ describe("doctor repair sequencing", () => {
const peerLinkCall = mocks.maybeRepairPluginOpenClawHostLinks.mock.calls[0]?.[0];
expect(peerLinkCall?.prompter).toEqual({ shouldRepair: true });
expect(peerLinkCall?.env).toBe(process.env);
expect(mocks.loadPluginMetadataSnapshot).toHaveBeenCalledOnce();
expect(result.pluginMetadataSnapshot).toBe(refreshedSnapshot);
});
it("repairs stale OAuth shadows before importing and removing auth JSON", async () => {
@@ -724,6 +745,7 @@ describe("doctor repair sequencing", () => {
changes: ['Installed missing configured plugin "mistral" from @openclaw/mistral-provider.'],
warnings: [],
repairedPluginIds: ["mistral"],
pluginInventoryChanged: true,
};
});
mocks.repairStaleAgentModelRefs.mockImplementationOnce((cfg: OpenClawConfig) => ({
@@ -817,6 +839,7 @@ describe("doctor repair sequencing", () => {
changes: ['Installed missing configured plugin "discord" from @openclaw/discord.'],
warnings: [],
repairedPluginIds: ["discord"],
pluginInventoryChanged: true,
});
mocks.materializePluginAutoEnableCandidates.mockImplementationOnce(
(params: { config: OpenClawConfig }) => ({
@@ -902,6 +925,7 @@ describe("doctor repair sequencing", () => {
changes: ['Installed missing configured plugin "exa" from @openclaw/exa-plugin.'],
warnings: [],
repairedPluginIds: ["exa"],
pluginInventoryChanged: true,
});
mocks.materializePluginAutoEnableCandidates.mockImplementationOnce(
(params: { config: OpenClawConfig }) => ({
@@ -932,8 +956,10 @@ describe("doctor repair sequencing", () => {
expect(mocks.materializePluginAutoEnableCandidates).toHaveBeenCalledWith({
config: {},
env: process.env,
manifestRegistry: { plugins: [], diagnostics: [] },
candidates: [{ pluginId: "exa", kind: "configured-plugin-repaired" }],
});
expect(mocks.loadPluginMetadataSnapshot).toHaveBeenCalledTimes(1);
expect(result.state.candidate.plugins?.entries?.exa).toEqual({ enabled: true });
expect(result.changeNotes).toStrictEqual([
'Installed missing configured plugin "exa" from @openclaw/exa-plugin.',
@@ -941,6 +967,136 @@ describe("doctor repair sequencing", () => {
]);
});
it("refreshes retained default-workspace metadata after cleanup-only inventory repairs", async () => {
const workspaceDir = "/tmp/openclaw-doctor-workspace";
const workspaceProvider = "workspace-provider";
const staleSnapshot = {
manifestRegistry: {
plugins: [{ id: "google-meet" }],
diagnostics: [],
},
};
const createRefreshedSnapshot = (includeWorkspaceProvider: boolean) =>
({
diagnostics: [],
manifestRegistry: { plugins: [], diagnostics: [] },
owners: {
providers: new Map(
includeWorkspaceProvider ? [[workspaceProvider, ["workspace-plugin"]]] : [],
),
modelCatalogProviders: new Map(),
setupProviders: new Map(),
cliBackends: new Map(),
},
}) as unknown as PluginMetadataSnapshot;
const refreshedSnapshot = createRefreshedSnapshot(true);
mocks.loadPluginMetadataSnapshot.mockImplementationOnce((params: { workspaceDir?: string }) =>
params.workspaceDir === workspaceDir ? refreshedSnapshot : createRefreshedSnapshot(false),
);
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({
changes: ['Removed stale managed install record for bundled plugin "google-meet".'],
warnings: [],
pluginInventoryChanged: true,
});
const { repairStaleAgentModelRefs: repairStaleAgentModelRefsActual } = await vi.importActual<
typeof import("./shared/stale-agent-model-ref-repair.js")
>("./shared/stale-agent-model-ref-repair.js");
mocks.repairStaleAgentModelRefs.mockImplementationOnce(
(
cfg: OpenClawConfig,
options: NonNullable<Parameters<typeof repairStaleAgentModelRefsActual>[1]>,
) =>
repairStaleAgentModelRefsActual(cfg, {
...options,
persistedProviderIdsByAgentId: new Map([["main", new Set()]]),
}),
);
const pluginMetadataSnapshotState = {
current: staleSnapshot as unknown as PluginMetadataSnapshot,
};
const scopedSnapshots: Array<PluginMetadataSnapshot | undefined> = [];
const runWithPluginMetadataSnapshot = <T>(
_scope: { config: OpenClawConfig; workspaceDir?: string },
run: () => T,
): T => {
scopedSnapshots.push(pluginMetadataSnapshotState.current);
return run();
};
const result = await runDoctorRepairSequence({
state: {
cfg: {
agents: {
defaults: {
model: `${workspaceProvider}/model`,
workspace: workspaceDir,
},
},
} as OpenClawConfig,
candidate: {
agents: {
defaults: {
model: `${workspaceProvider}/model`,
workspace: workspaceDir,
},
},
} as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
doctorFixCommand: "openclaw doctor --fix",
pluginMetadataSnapshotState,
runWithPluginMetadataSnapshot,
});
expect(mocks.loadPluginMetadataSnapshot).toHaveBeenCalledWith({
config: {
agents: {
defaults: {
model: `${workspaceProvider}/model`,
workspace: workspaceDir,
},
},
},
env: process.env,
workspaceDir,
});
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
config: {
agents: {
defaults: {
model: `${workspaceProvider}/model`,
workspace: workspaceDir,
},
},
},
env: process.env,
manifestRegistry: refreshedSnapshot.manifestRegistry,
});
expect(mocks.repairStaleAgentModelRefs).toHaveBeenCalledWith(
{
agents: {
defaults: {
model: `${workspaceProvider}/model`,
workspace: workspaceDir,
},
},
},
{
env: process.env,
pluginMetadataSnapshot: refreshedSnapshot,
},
);
expect(pluginMetadataSnapshotState.current).toBe(refreshedSnapshot);
expect(scopedSnapshots[0]).toBe(staleSnapshot);
expect(scopedSnapshots).toContain(refreshedSnapshot);
expect(result.pluginMetadataSnapshot).toBe(refreshedSnapshot);
expect(result.state.candidate.agents?.defaults?.model).toBe(`${workspaceProvider}/model`);
expect(result.changeNotes).not.toContain(
expect.stringContaining(`provider "${workspaceProvider}" is unavailable`),
);
});
it("surfaces ClawHub notices from successful missing configured plugin repair", async () => {
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({
changes: ['Installed missing configured plugin "brave" from @openclaw/brave-plugin.'],
+113 -39
View File
@@ -1,10 +1,16 @@
// Doctor repair sequence coordinator for config, auth, plugin, and warning repairs.
import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
applyPluginAutoEnable,
materializePluginAutoEnableCandidates,
} from "../../config/plugin-auto-enable.js";
import { migrateLegacyOnboardingRecommendationsScope } from "../../infra/state-migrations.onboarding-recommendations.js";
import type { PluginMetadataSnapshotScopeRunner } from "../../plugins/current-plugin-metadata-snapshot.js";
import {
loadPluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "../../plugins/plugin-metadata-snapshot.js";
import { migrateLegacyTailscaleProfileIdentities } from "../../state/user-profiles-tailscale-migration.js";
import {
collectOpenAICodexAuthProfileStoreIdMap,
@@ -38,6 +44,7 @@ import { maybeRepairLegacyToolsBySenderKeys } from "./shared/legacy-tools-by-sen
import { repairMissingConfiguredPluginInstalls } from "./shared/missing-configured-plugin-install.js";
import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js";
import { cleanupLegacyPluginDependencyState } from "./shared/plugin-dependency-cleanup.js";
import type { DoctorPluginMetadataSnapshotState } from "./shared/plugin-metadata-snapshot-scope.js";
import { repairStaleAgentModelRefs } from "./shared/stale-agent-model-ref-repair.js";
import { maybeRepairStaleConfiguredAuthOrders } from "./shared/stale-auth-order.js";
import { repairStaleOAuthProfileShadows } from "./shared/stale-oauth-profile-shadows.js";
@@ -51,17 +58,28 @@ export async function runDoctorRepairSequence(params: {
doctorFixCommand: string;
env?: NodeJS.ProcessEnv;
blockedCodexProviderPlan?: BlockedLegacyOpenAICodexProviderPlan;
pluginMetadataSnapshotState?: DoctorPluginMetadataSnapshotState;
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
}): Promise<{
state: DoctorConfigMutationState;
changeNotes: string[];
warningNotes: string[];
authProfilesRepaired: boolean;
openAICodexAuthProfileIdMap?: ReadonlyMap<string, string>;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
}> {
let state = params.state;
const pluginMetadataSnapshotState = params.pluginMetadataSnapshotState ?? {};
const changeNotes: string[] = [];
const warningNotes: string[] = [];
const env = params.env ?? process.env;
const resolveCurrentPluginMetadataScope = () => {
const config = state.candidate;
return {
config,
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config), env),
};
};
const sanitizeLines = (lines: string[]) => lines.map((line) => sanitizeForLog(line)).join("\n");
const appendNotes = (notes: string[], lines: string[] | undefined): void => {
if (lines && lines.length > 0) {
@@ -77,6 +95,12 @@ export async function runDoctorRepairSequence(params: {
appendNotes(warningNotes, repair.warnings);
appendNotes(warningNotes, repair.notices);
};
const runWithCurrentPluginMetadata = <T>(run: () => T): T => {
if (!params.runWithPluginMetadataSnapshot) {
return run();
}
return params.runWithPluginMetadataSnapshot(resolveCurrentPluginMetadataScope(), run);
};
const applyMutation = (mutation: {
config: DoctorConfigMutationState["candidate"];
@@ -108,33 +132,38 @@ export async function runDoctorRepairSequence(params: {
for (const repair of stages) {
// Each descriptor consumes the previous repair's candidate; changing the
// order can break owner repairs, allowlist inheritance, or upgrade safety.
applyMutation(await repair(state.candidate));
applyMutation(await runWithCurrentPluginMetadata(() => repair(state.candidate)));
}
};
for (const mutation of await collectChannelDoctorRepairMutations({
cfg: state.candidate,
doctorFixCommand: params.doctorFixCommand,
env,
})) {
const initialChannelRepairs = await runWithCurrentPluginMetadata(() =>
collectChannelDoctorRepairMutations({
cfg: state.candidate,
doctorFixCommand: params.doctorFixCommand,
env,
}),
);
for (const mutation of initialChannelRepairs) {
applyMutation(mutation);
}
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env));
maybeRepairStaleManagedNpmBundledPlugins({
const removedStaleManagedNpmBundledPlugins = maybeRepairStaleManagedNpmBundledPlugins({
config: state.candidate,
env,
prompter: { shouldRepair: true },
});
await maybeRepairPluginOpenClawHostLinks({
const repairedPluginOpenClawHostLinks = await maybeRepairPluginOpenClawHostLinks({
env,
prompter: { shouldRepair: true },
});
const codexRouteRepair = maybeRepairCodexRoutes({
cfg: state.candidate,
env,
shouldRepair: true,
blockedProviderPlan: params.blockedCodexProviderPlan,
});
const codexRouteRepair = runWithCurrentPluginMetadata(() =>
maybeRepairCodexRoutes({
cfg: state.candidate,
env,
shouldRepair: true,
blockedProviderPlan: params.blockedCodexProviderPlan,
}),
);
applyMutation({
config: codexRouteRepair.cfg,
changes: codexRouteRepair.changes,
@@ -152,25 +181,50 @@ export async function runDoctorRepairSequence(params: {
}),
);
applyMutation(
await maybeRepairContextEngineHostCompatibility({
await runWithCurrentPluginMetadata(() =>
maybeRepairContextEngineHostCompatibility({
cfg: state.candidate,
doctorFixCommand: params.doctorFixCommand,
env,
}),
),
);
const missingConfiguredPluginInstallRepair = await runWithCurrentPluginMetadata(() =>
repairMissingConfiguredPluginInstalls({
cfg: state.candidate,
doctorFixCommand: params.doctorFixCommand,
env,
}),
);
const missingConfiguredPluginInstallRepair = await repairMissingConfiguredPluginInstalls({
cfg: state.candidate,
env,
});
const repairedPluginIds = missingConfiguredPluginInstallRepair.repairedPluginIds ?? [];
if (
removedStaleManagedNpmBundledPlugins ||
repairedPluginOpenClawHostLinks ||
missingConfiguredPluginInstallRepair.pluginInventoryChanged
) {
// Inventory repair changes the authoritative plugin generation. Replace the
// shared Doctor base before later discovery so nested scopes cannot reuse stale metadata.
const currentScope = resolveCurrentPluginMetadataScope();
pluginMetadataSnapshotState.current = loadPluginMetadataSnapshot({
config: currentScope.config,
env,
workspaceDir: currentScope.workspaceDir,
});
}
if (missingConfiguredPluginInstallRepair.changes.length > 0) {
appendNotes(changeNotes, missingConfiguredPluginInstallRepair.changes);
applyMutation(applyPluginAutoEnable({ config: state.candidate, env }));
const repairedPluginIds = missingConfiguredPluginInstallRepair.repairedPluginIds ?? [];
applyMutation(
applyPluginAutoEnable({
config: state.candidate,
env,
manifestRegistry: pluginMetadataSnapshotState.current?.manifestRegistry,
}),
);
if (repairedPluginIds.length > 0) {
applyMutation(
materializePluginAutoEnableCandidates({
config: state.candidate,
env,
manifestRegistry: pluginMetadataSnapshotState.current?.manifestRegistry,
candidates: repairedPluginIds.map((pluginId) => ({
pluginId,
kind: "configured-plugin-repaired" as const,
@@ -180,14 +234,22 @@ export async function runDoctorRepairSequence(params: {
// Missing external plugins cannot expose their doctor contracts until
// installation completes. Normalize legacy shapes before channel repair
// so later validation and gateway restart consume canonical config.
for (const mutation of collectChannelDoctorCompatibilityMutations(state.candidate, { env })) {
const channelCompatibilityMutations = runWithCurrentPluginMetadata(() =>
collectChannelDoctorCompatibilityMutations(state.candidate, {
env,
}),
);
for (const mutation of channelCompatibilityMutations) {
applyMutation(mutation);
}
for (const mutation of await collectChannelDoctorRepairMutations({
cfg: state.candidate,
doctorFixCommand: params.doctorFixCommand,
env,
})) {
const channelRepairs = await runWithCurrentPluginMetadata(() =>
collectChannelDoctorRepairMutations({
cfg: state.candidate,
doctorFixCommand: params.doctorFixCommand,
env,
}),
);
for (const mutation of channelRepairs) {
applyMutation(mutation);
}
}
@@ -203,16 +265,23 @@ export async function runDoctorRepairSequence(params: {
if (pluginInstallRepairConverged) {
// Provider availability is authoritative only after configured plugin repair
// converges. Preserve model refs while package installation still needs a retry.
applyMutation(repairStaleAgentModelRefs(state.candidate, { env }));
applyMutation(
repairStaleAgentModelRefs(state.candidate, {
env,
pluginMetadataSnapshot: pluginMetadataSnapshotState.current,
}),
);
}
if (!packageSwapInProgress && !hasUnscopedInstallRepairWarnings) {
applyMutation(
maybeRepairStalePluginConfig(state.candidate, env, {
preservePluginIds: failedPluginIds,
// A host-version-bound runtime can be absent between core swap and package
// convergence. Preserve its allow, deny, and explicit enable/disable policy.
surfacePreservePluginIds: VERSION_BOUND_RUNTIME_PLUGIN_POLICY_IDS_BY_SURFACE,
}),
runWithCurrentPluginMetadata(() =>
maybeRepairStalePluginConfig(state.candidate, env, {
preservePluginIds: failedPluginIds,
// A host-version-bound runtime can be absent between core swap and package
// convergence. Preserve its allow, deny, and explicit enable/disable policy.
surfacePreservePluginIds: VERSION_BOUND_RUNTIME_PLUGIN_POLICY_IDS_BY_SURFACE,
}),
),
);
}
await applyRepairStages([
@@ -223,10 +292,12 @@ export async function runDoctorRepairSequence(params: {
maybeRepairStaleSubagentAllowlists,
]);
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, {
doctorFixCommand: params.doctorFixCommand,
...createChannelDoctorEmptyAllowlistPolicyHooks({ cfg: state.candidate, env }),
});
const emptyAllowlistWarnings = runWithCurrentPluginMetadata(() =>
scanEmptyAllowlistPolicyWarnings(state.candidate, {
doctorFixCommand: params.doctorFixCommand,
...createChannelDoctorEmptyAllowlistPolicyHooks({ cfg: state.candidate, env }),
}),
);
appendNotes(warningNotes, emptyAllowlistWarnings);
await applyRepairStages([maybeRepairLegacyToolsBySenderKeys, maybeRepairExecSafeBinProfiles]);
@@ -284,5 +355,8 @@ export async function runDoctorRepairSequence(params: {
warningNotes,
authProfilesRepaired,
...(openAICodexAuthProfileIdMap.size > 0 ? { openAICodexAuthProfileIdMap } : {}),
...(pluginMetadataSnapshotState.current
? { pluginMetadataSnapshot: pluginMetadataSnapshotState.current }
: {}),
};
}
@@ -17,6 +17,7 @@ import { buildReadableToolsByName } from "../../../agents/tools-effective-invent
import type { AnyAgentTool } from "../../../agents/tools/common.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { formatErrorMessage } from "../../../infra/errors.js";
import type { PluginMetadataSnapshotScopeRunner } from "../../../plugins/current-plugin-metadata-snapshot.js";
import { extractModelCompat } from "../../../plugins/provider-model-compat.js";
import type { ProviderRuntimeModel } from "../../../plugins/provider-runtime-model.types.js";
import { getPluginToolMeta } from "../../../plugins/tools.js";
@@ -94,6 +95,7 @@ function readPluginId(tool: AnyAgentTool | undefined): string | undefined {
export async function collectActiveToolSchemaProjectionWarnings(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
}): Promise<string[]> {
if (params.cfg.plugins?.enabled === false) {
return [];
@@ -103,98 +105,110 @@ export async function collectActiveToolSchemaProjectionWarnings(params: {
const warnings: string[] = [];
for (const agentId of listAgentIds(params.cfg)) {
const agentConfig = resolveAgentConfig(params.cfg, agentId);
const modelRef = resolveDoctorPrimaryModelRef(params.cfg, agentConfig?.model);
const agentDir = resolveAgentDir(params.cfg, agentId, env);
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId, env);
let runtimeModelContext: RuntimeModelContext = {};
try {
runtimeModelContext = await resolveRuntimeModelContext({
cfg: params.cfg,
agentId,
agentDir,
workspaceDir,
provider: modelRef.provider,
modelId: modelRef.model,
});
} catch (error) {
warnings.push(
sanitizeForLog(
`- agents.${agentId}: active tool schema validation could not resolve the runtime model context (${formatErrorMessage(error)}). Fix provider/model loading errors before relying on assistant tool startup.`,
),
);
}
let tools: ReturnType<typeof createOpenClawCodingTools>;
try {
tools = createOpenClawCodingTools({
agentId,
agentDir,
workspaceDir,
config: params.cfg,
modelProvider: modelRef.provider,
modelId: modelRef.model,
modelApi: runtimeModelContext.modelApi,
modelCompat: runtimeModelContext.modelCompat,
modelContextWindowTokens: runtimeModelContext.modelContextWindowTokens,
allowGatewaySubagentBinding: true,
toolPolicyAuditLogLevel: "debug",
});
} catch (error) {
warnings.push(
sanitizeForLog(
`- agents.${agentId}: active tool schema validation could not load the runtime tool set (${formatErrorMessage(error)}). Fix plugin loading errors before relying on assistant tool startup.`,
),
);
continue;
}
const collectForAgent = async (): Promise<string[]> => {
const agentWarnings: string[] = [];
const modelRef = resolveDoctorPrimaryModelRef(params.cfg, agentConfig?.model);
let runtimeModelContext: RuntimeModelContext = {};
try {
runtimeModelContext = await resolveRuntimeModelContext({
cfg: params.cfg,
agentId,
agentDir,
workspaceDir,
provider: modelRef.provider,
modelId: modelRef.model,
});
} catch (error) {
agentWarnings.push(
sanitizeForLog(
`- agents.${agentId}: active tool schema validation could not resolve the runtime model context (${formatErrorMessage(error)}). Fix provider/model loading errors before relying on assistant tool startup.`,
),
);
}
let tools: ReturnType<typeof createOpenClawCodingTools>;
try {
tools = createOpenClawCodingTools({
agentId,
agentDir,
workspaceDir,
config: params.cfg,
modelProvider: modelRef.provider,
modelId: modelRef.model,
modelApi: runtimeModelContext.modelApi,
modelCompat: runtimeModelContext.modelCompat,
modelContextWindowTokens: runtimeModelContext.modelContextWindowTokens,
allowGatewaySubagentBinding: true,
toolPolicyAuditLogLevel: "debug",
});
} catch (error) {
agentWarnings.push(
sanitizeForLog(
`- agents.${agentId}: active tool schema validation could not load the runtime tool set (${formatErrorMessage(error)}). Fix plugin loading errors before relying on assistant tool startup.`,
),
);
return agentWarnings;
}
const rawToolsByName = buildReadableToolsByName(tools);
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
let normalizedTools: typeof tools;
try {
normalizedTools = normalizeAgentRuntimeTools({
tools,
provider: modelRef.provider,
config: params.cfg,
workspaceDir,
env,
modelId: modelRef.model,
modelApi: runtimeModelContext.modelApi,
model: runtimeModelContext.model,
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
preNormalizationDiagnostics.push(...diagnostics),
});
} catch (error) {
warnings.push(
sanitizeForLog(
`- agents.${agentId}: active tool schema validation could not normalize the runtime tool set (${formatErrorMessage(error)}). Fix provider/plugin loading errors before relying on assistant tool startup.`,
),
);
continue;
}
for (const diagnostic of preNormalizationDiagnostics) {
const rawTool = rawToolsByName.get(diagnostic.toolName);
const pluginId = readPluginId(rawTool);
warnings.push(
formatDiagnostic({
agentId,
diagnostic,
...(pluginId ? { pluginId } : {}),
}),
);
}
const projection = filterRuntimeCompatibleTools(normalizedTools);
for (const diagnostic of projection.diagnostics) {
const tool = readToolByIndex(normalizedTools, diagnostic.toolIndex);
const rawTool = rawToolsByName.get(diagnostic.toolName);
const pluginId = readPluginId(tool) ?? readPluginId(rawTool);
warnings.push(
formatDiagnostic({
agentId,
diagnostic,
...(pluginId ? { pluginId } : {}),
}),
);
}
const rawToolsByName = buildReadableToolsByName(tools);
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
let normalizedTools: typeof tools;
try {
normalizedTools = normalizeAgentRuntimeTools({
tools,
provider: modelRef.provider,
config: params.cfg,
workspaceDir,
env,
modelId: modelRef.model,
modelApi: runtimeModelContext.modelApi,
model: runtimeModelContext.model,
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
preNormalizationDiagnostics.push(...diagnostics),
});
} catch (error) {
agentWarnings.push(
sanitizeForLog(
`- agents.${agentId}: active tool schema validation could not normalize the runtime tool set (${formatErrorMessage(error)}). Fix provider/plugin loading errors before relying on assistant tool startup.`,
),
);
return agentWarnings;
}
for (const diagnostic of preNormalizationDiagnostics) {
const rawTool = rawToolsByName.get(diagnostic.toolName);
const pluginId = readPluginId(rawTool);
agentWarnings.push(
formatDiagnostic({
agentId,
diagnostic,
...(pluginId ? { pluginId } : {}),
}),
);
}
const projection = filterRuntimeCompatibleTools(normalizedTools);
for (const diagnostic of projection.diagnostics) {
const tool = readToolByIndex(normalizedTools, diagnostic.toolIndex);
const rawTool = rawToolsByName.get(diagnostic.toolName);
const pluginId = readPluginId(tool) ?? readPluginId(rawTool);
agentWarnings.push(
formatDiagnostic({
agentId,
diagnostic,
...(pluginId ? { pluginId } : {}),
}),
);
}
return agentWarnings;
};
warnings.push(
...(params.runWithPluginMetadataSnapshot
? await params.runWithPluginMetadataSnapshot(
{ config: params.cfg, workspaceDir },
collectForAgent,
)
: await collectForAgent()),
);
}
return warnings;
@@ -2,12 +2,18 @@
import { collectChannelLegacyConfigRules } from "../../../channels/plugins/legacy-config.js";
import { findLegacyConfigIssues } from "../../../config/legacy.js";
import type { LegacyConfigRule } from "../../../config/legacy.shared.js";
import type { LegacyConfigIssue, OpenClawConfig } from "../../../config/types.js";
import type {
ConfigFileSnapshot,
LegacyConfigIssue,
OpenClawConfig,
} from "../../../config/types.js";
import { withPluginMetadataSnapshotScope } from "../../../plugins/current-plugin-metadata-snapshot.js";
import {
collectRelevantDoctorPluginIds,
collectRelevantDoctorPluginIdsForTouchedPaths,
listPluginDoctorLegacyConfigRules,
} from "../../../plugins/doctor-contract-registry.js";
import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js";
function collectConfiguredChannelIds(raw: unknown): ReadonlySet<string> {
if (!raw || typeof raw !== "object") {
@@ -52,3 +58,21 @@ export function findDoctorLegacyConfigIssues(
touchedPaths,
);
}
export function addDoctorLegacyIssues(
snapshot: ConfigFileSnapshot,
pluginMetadataSnapshot?: PluginMetadataSnapshot,
): ConfigFileSnapshot {
if (!snapshot.exists) {
return snapshot;
}
const resolvedRaw = snapshot.sourceConfig ?? snapshot.config ?? {};
const collect = () => {
const sourceRaw = snapshot.parsed ?? resolvedRaw;
const legacyIssues = findDoctorLegacyConfigIssues(resolvedRaw, sourceRaw);
return legacyIssues.length === 0 ? snapshot : { ...snapshot, legacyIssues };
};
return pluginMetadataSnapshot
? withPluginMetadataSnapshotScope(pluginMetadataSnapshot, collect, { config: resolvedRaw })
: collect();
}
@@ -46,6 +46,8 @@ type RepairMissingPluginInstallsResult = {
warnings: string[];
/** Plugin ids successfully repaired from current configuration. */
repairedPluginIds?: string[];
/** Successful install-record or package repairs that invalidate retained metadata. */
pluginInventoryChanged?: true;
/** User-facing details for repairs explicitly deferred until post-core convergence. */
deferredRepairDetails?: string[];
/** Plugin ids whose install repair failed and should be preserved from cleanup passes. */
@@ -386,6 +388,7 @@ async function repairMissingPluginInstalls(params: {
// a stale snapshot.
await writePersistedInstalledPluginIndexInstallRecords(nextRecords, persistedIndexOptions);
}
const pluginInventoryChanged = nextRecords !== records || repairedPluginIds.size > 0;
return {
changes,
warnings,
@@ -398,6 +401,7 @@ async function repairMissingPluginInstalls(params: {
),
}
: {}),
...(pluginInventoryChanged ? { pluginInventoryChanged: true as const } : {}),
...(failedPluginIds.size > 0
? {
failedPluginIds: [...failedPluginIds].toSorted((left, right) =>
@@ -1290,6 +1290,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result).toEqual({
changes: ['Removed stale managed install record for bundled plugin "matrix".'],
warnings: [],
pluginInventoryChanged: true,
records: {},
});
});
@@ -1361,6 +1362,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result).toEqual({
changes: ['Removed stale managed install record for bundled plugin "google-meet".'],
warnings: [],
pluginInventoryChanged: true,
records: {},
});
});
@@ -1409,6 +1411,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result).toEqual({
changes: ['Removed stale managed install record for bundled plugin "google-meet".'],
warnings: [],
pluginInventoryChanged: true,
records: {},
});
});
@@ -3519,6 +3522,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
changes: ['Repaired missing configured plugin "discord".'],
warnings: [],
repairedPluginIds: ["discord"],
pluginInventoryChanged: true,
records: installedRecords("discord", {
spec: "@openclaw/discord",
installPath: process.cwd(),
@@ -3661,6 +3665,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
],
warnings: [],
repairedPluginIds: [pluginId],
pluginInventoryChanged: true,
records: persistedRecords,
});
});
@@ -0,0 +1,102 @@
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import {
withPluginMetadataSnapshotScope,
type PluginMetadataSnapshotScopeRunner,
} from "../../../plugins/current-plugin-metadata-snapshot.js";
import {
isPluginMetadataSnapshotCompatible,
loadPluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "../../../plugins/plugin-metadata-snapshot.js";
export type DoctorPluginMetadataSnapshotState = {
current?: PluginMetadataSnapshot;
};
type DoctorPluginMetadataSnapshotScope = {
run: PluginMetadataSnapshotScopeRunner;
invalidate: () => void;
};
/** Promotes validation-scoped metadata to a complete immutable Doctor snapshot. */
export function completeDoctorPluginMetadataSnapshot(params: {
snapshot?: PluginMetadataSnapshot;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): PluginMetadataSnapshot | undefined {
if (!params.snapshot || params.snapshot.pluginIds === undefined) {
return params.snapshot;
}
return loadPluginMetadataSnapshot({
config: params.config,
env: params.env ?? process.env,
index: params.snapshot.index,
...(params.snapshot.workspaceDir ? { workspaceDir: params.snapshot.workspaceDir } : {}),
});
}
/** Reuses one exact immutable plugin metadata generation per Doctor workspace. */
export function createDoctorPluginMetadataSnapshotScope(params: {
baseSnapshot?: PluginMetadataSnapshot;
getBaseSnapshot?: () => PluginMetadataSnapshot | undefined;
env?: NodeJS.ProcessEnv;
}): DoctorPluginMetadataSnapshotScope {
const env = params.env ?? process.env;
const snapshotsByWorkspace = new Map<string | undefined, PluginMetadataSnapshot>();
const readBaseSnapshot = () => params.getBaseSnapshot?.() ?? params.baseSnapshot;
let currentBaseSnapshot: PluginMetadataSnapshot | undefined;
const refreshBaseSnapshot = () => {
const nextBaseSnapshot = readBaseSnapshot();
if (nextBaseSnapshot === currentBaseSnapshot) {
return;
}
currentBaseSnapshot = nextBaseSnapshot;
snapshotsByWorkspace.clear();
if (nextBaseSnapshot && nextBaseSnapshot.pluginIds === undefined) {
snapshotsByWorkspace.set(nextBaseSnapshot.workspaceDir, nextBaseSnapshot);
}
};
const resolveSnapshot = (config: OpenClawConfig, workspaceDir: string | undefined) => {
refreshBaseSnapshot();
const current = snapshotsByWorkspace.get(workspaceDir);
if (
current &&
isPluginMetadataSnapshotCompatible({
snapshot: current,
config,
env,
workspaceDir,
})
) {
return current;
}
const snapshot = loadPluginMetadataSnapshot({
config,
env,
...(workspaceDir ? { workspaceDir } : {}),
});
snapshotsByWorkspace.set(workspaceDir, snapshot);
return snapshot;
};
const run: PluginMetadataSnapshotScopeRunner = (scope, operation) => {
const snapshot = resolveSnapshot(scope.config, scope.workspaceDir);
return withPluginMetadataSnapshotScope(snapshot, operation, {
config: scope.config,
env,
...(scope.workspaceDir ? { workspaceDir: scope.workspaceDir } : {}),
});
};
return {
run,
invalidate: () => {
// Inventory repairs invalidate every derived workspace generation even
// when updater preflight intentionally left the base snapshot absent.
currentBaseSnapshot = undefined;
snapshotsByWorkspace.clear();
},
};
}
@@ -28,14 +28,19 @@ const DOCTOR_PLUGIN_ID_ALIASES: Readonly<Record<string, readonly string[]>> = {
openai: ["openai-codex"],
};
type PluginRegistryInstallMigrationPreflightAction = "skip-existing" | "migrate";
type PluginRegistryInstallMigrationPreflight = {
/** Migration action selected before reading or writing registry state. */
action: PluginRegistryInstallMigrationPreflightAction;
/** Persisted plugin index path that migration will inspect or write. */
filePath: string;
};
type PluginRegistryInstallMigrationPreflight =
| {
/** Migration action selected before reading or writing registry state. */
action: "skip-existing";
/** Persisted plugin index path that migration will inspect or write. */
filePath: string;
/** Authoritative pre-repair generation used to detect a real inventory change. */
current: InstalledPluginIndex;
}
| {
action: "migrate";
filePath: string;
};
type PluginRegistryInstallMigrationResult =
| {
@@ -70,6 +75,7 @@ export function preflightPluginRegistryInstallMigration(
return {
action: "skip-existing",
filePath,
current: currentRegistry,
};
}
}
@@ -70,6 +70,7 @@ const staleAuthOrderState = vi.hoisted(() => ({
const activeToolSchemaState = vi.hoisted(() => ({
warnings: [] as string[],
params: undefined as { runWithPluginMetadataSnapshot?: unknown } | undefined,
}));
const commandSecretState = vi.hoisted(() => ({
@@ -315,7 +316,12 @@ vi.mock("./stale-auth-order.js", () => ({
}));
vi.mock("./active-tool-schema-warnings.js", () => ({
collectActiveToolSchemaProjectionWarnings: async () => activeToolSchemaState.warnings,
collectActiveToolSchemaProjectionWarnings: async (params: {
runWithPluginMetadataSnapshot?: unknown;
}) => {
activeToolSchemaState.params = params;
return activeToolSchemaState.warnings;
},
}));
vi.mock("./codex-route-warnings.js", () => ({
@@ -389,6 +395,7 @@ describe("doctor preview warnings", () => {
staleOAuthShadowState.warnings = [];
staleAuthOrderState.warnings = [];
activeToolSchemaState.warnings = [];
activeToolSchemaState.params = undefined;
commandSecretState.targetIds = new Set<string>();
commandSecretState.resolvedConfig = undefined;
commandSecretState.diagnostics = [];
@@ -698,6 +705,23 @@ describe("doctor preview warnings", () => {
).toBe(true);
});
it("scopes active tool schema preview checks to the Doctor metadata lifecycle", async () => {
const runWithPluginMetadataSnapshot = <T>(
_scope: { config: OpenClawConfig; workspaceDir?: string },
run: () => T,
): T => run();
await collectDoctorPreviewWarnings({
cfg: {},
doctorFixCommand: "openclaw doctor --fix",
runWithPluginMetadataSnapshot,
});
expect(activeToolSchemaState.params?.runWithPluginMetadataSnapshot).toBe(
runWithPluginMetadataSnapshot,
);
});
it("warns but skips auto-removal when plugin discovery has errors", async () => {
manifestState.plugins = [];
manifestState.diagnostics = [
+11 -1
View File
@@ -18,6 +18,7 @@ import type {
ToolPolicyConfig,
ToolsConfig,
} from "../../../config/types.tools.js";
import type { PluginMetadataSnapshotScopeRunner } from "../../../plugins/current-plugin-metadata-snapshot.js";
import { collectChannelRouteTargets } from "../../../routing/channel-route-targets.js";
import { createLazyImportLoader } from "../../../shared/lazy-promise.js";
import { VERSION_BOUND_RUNTIME_PLUGIN_POLICY_IDS_BY_SURFACE } from "./configured-runtime-plugin-installs.js";
@@ -710,6 +711,7 @@ export async function collectDoctorPreviewNotes(params: {
env?: NodeJS.ProcessEnv;
allowExec?: boolean;
blockedCodexProviderPlan?: BlockedLegacyOpenAICodexProviderPlan;
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
}): Promise<DoctorPreviewNotes> {
const infoNotes: string[] = [];
const warnings: string[] = [];
@@ -722,7 +724,15 @@ export async function collectDoctorPreviewNotes(params: {
warnings.push(...collectProfileConfiguredToolSectionWarnings(params.cfg));
const { collectActiveToolSchemaProjectionWarnings } =
await import("./active-tool-schema-warnings.js");
warnings.push(...(await collectActiveToolSchemaProjectionWarnings({ cfg: params.cfg, env })));
warnings.push(
...(await collectActiveToolSchemaProjectionWarnings({
cfg: params.cfg,
env,
...(params.runWithPluginMetadataSnapshot
? { runWithPluginMetadataSnapshot: params.runWithPluginMetadataSnapshot }
: {}),
})),
);
const channelPluginRuntime = await import("./channel-plugin-blockers.js");
const channelPluginBlockerHits = channelPluginRuntime.scanConfiguredChannelPluginBlockers(
@@ -550,6 +550,7 @@ describe("configured plugin install release step", () => {
mocks.repairMissingPluginInstallsForIds.mockResolvedValue({
changes: ['Installed missing configured plugin "codex".'],
warnings: [],
pluginInventoryChanged: true,
});
const result = await maybeRunConfiguredPluginInstallReleaseStep({
cfg: {
@@ -571,6 +572,7 @@ describe("configured plugin install release step", () => {
expect(repairCall.env).toEqual({});
expect(result.touchedConfig).toBe(true);
expect(result.completed).toBe(true);
expect(result.pluginInventoryChanged).toBe(true);
});
it("surfaces non-fatal repair notices without blocking release repair completion", async () => {
@@ -350,6 +350,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: {
warnings: string[];
completed: boolean;
touchedConfig: boolean;
pluginInventoryChanged?: true;
postInstallDoctorResult?: UpdatePostInstallDoctorResult;
}> {
const env = params.env ?? process.env;
@@ -381,6 +382,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: {
warnings,
completed: repaired.warnings.length === 0,
touchedConfig: false,
...(repaired.pluginInventoryChanged ? { pluginInventoryChanged: true as const } : {}),
...(postInstallDoctorResult ? { postInstallDoctorResult } : {}),
};
}
@@ -406,6 +408,7 @@ export async function maybeRunConfiguredPluginInstallReleaseStep(params: {
warnings,
completed,
touchedConfig: completed,
...(repaired.pluginInventoryChanged ? { pluginInventoryChanged: true as const } : {}),
...(postInstallDoctorResult ? { postInstallDoctorResult } : {}),
};
}
@@ -13,6 +13,7 @@ import { normalizeProviderId } from "../../../agents/model-selection.js";
import type { AgentModelConfig } from "../../../config/types.agents-shared.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { resolvePluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js";
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js";
import { collectConfiguredProviderSelectionIds } from "./configured-provider-selection-ids.js";
@@ -25,6 +26,7 @@ type StaleAgentModelRefRepair = {
type RepairOptions = {
env?: NodeJS.ProcessEnv;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
/** Test seam for the provider ids supplied by bundled or installed plugins. */
pluginProviderIds?: ReadonlySet<string>;
/** Test seam for provider ids already present in each agent's models.json. */
@@ -53,12 +55,14 @@ function collectPluginProviderIds(
} else {
const defaultAgentId = tryResolveDefaultAgentId(cfg);
const workspaceDir = defaultAgentId ? resolveAgentWorkspaceDir(cfg, defaultAgentId) : undefined;
const snapshot = resolvePluginMetadataSnapshot({
config: cfg,
workspaceDir: workspaceDir ?? undefined,
env: options.env ?? process.env,
allowWorkspaceScopedCurrent: true,
});
const snapshot =
options.pluginMetadataSnapshot ??
resolvePluginMetadataSnapshot({
config: cfg,
workspaceDir: workspaceDir ?? undefined,
env: options.env ?? process.env,
allowWorkspaceScopedCurrent: true,
});
if (snapshot.diagnostics.some((diagnostic) => diagnostic.level === "error")) {
return {
warnings: [
+76 -67
View File
@@ -44,6 +44,7 @@ import {
formatLocalAudioSelection,
inspectLocalAudioSelection,
} from "../media-understanding/local-audio.js";
import type { PluginMetadataSnapshotScopeRunner } from "../plugins/current-plugin-metadata-snapshot.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import { getPluginToolMeta, setPluginToolMeta } from "../plugins/tools.js";
import type { ProviderCatalogOrder, ProviderPlugin } from "../plugins/types.js";
@@ -1069,6 +1070,7 @@ function isAcpRuntimeAgent(cfg: OpenClawConfig, agentId: string): boolean {
export async function collectRuntimeToolSchemaFindings(
cfg: OpenClawConfig,
options?: { runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner },
): Promise<readonly HealthFinding[]> {
const findings: HealthFinding[] = [];
const bundleRuntimeByWorkspace = new Map<string, BundleMcpToolRuntime>();
@@ -1079,78 +1081,28 @@ export async function collectRuntimeToolSchemaFindings(
if (isAcpRuntimeAgent(cfg, agentId)) {
continue;
}
const catalog = await loadPreparedModelCatalog({
config: cfg,
agentId,
agentDir: resolveAgentDir(cfg, agentId),
});
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const modelRef = resolveDefaultModelForAgent({
cfg,
agentId,
allowPluginNormalization: true,
});
const model = buildDoctorRuntimeModel({
entry: findModelInCatalog(catalog, modelRef.provider, modelRef.model),
provider: modelRef.provider,
modelId: modelRef.model,
});
if (!supportsModelTools(model)) {
continue;
}
findings.push(
...collectAgentRuntimeToolSchemaFindings({
const collectForAgent = async () => {
const catalog = await loadPreparedModelCatalog({
config: cfg,
agentId,
agentDir: resolveAgentDir(cfg, agentId),
});
const modelRef = resolveDefaultModelForAgent({
cfg,
agentId,
workspaceDir,
modelRef,
model,
}),
);
if (!shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true })) {
continue;
}
if (
!bundleRuntimeByWorkspace.has(workspaceDir) &&
!bundleRuntimeLoadErrorsByWorkspace.has(workspaceDir)
) {
try {
bundleRuntimeByWorkspace.set(
workspaceDir,
await createBundleMcpToolRuntime({
workspaceDir,
cfg,
}),
);
} catch (error) {
bundleRuntimeLoadErrorsByWorkspace.set(
workspaceDir,
bundleMcpRuntimeLoadFailureFinding(error),
);
}
}
const bundleRuntimeLoadError = bundleRuntimeLoadErrorsByWorkspace.get(workspaceDir);
if (bundleRuntimeLoadError) {
if (!reportedBundleRuntimeLoadErrors.has(workspaceDir)) {
findings.push(bundleRuntimeLoadError);
reportedBundleRuntimeLoadErrors.add(workspaceDir);
}
continue;
}
const bundleRuntime = bundleRuntimeByWorkspace.get(workspaceDir);
if (bundleRuntime) {
if (bundleRuntime.diagnostics && bundleRuntime.diagnostics.length > 0) {
const policyActiveDiagnostics = filterPolicyActiveBundleMcpDiagnostics({
diagnostics: bundleRuntime.diagnostics,
cfg,
agentId,
modelRef,
});
findings.push(...policyActiveDiagnostics.map(bundleMcpRuntimeDiagnosticFinding));
allowPluginNormalization: true,
});
const model = buildDoctorRuntimeModel({
entry: findModelInCatalog(catalog, modelRef.provider, modelRef.model),
provider: modelRef.provider,
modelId: modelRef.model,
});
if (!supportsModelTools(model)) {
return;
}
findings.push(
...collectBundleMcpRuntimeToolSchemaFindings({
bundleRuntime,
...collectAgentRuntimeToolSchemaFindings({
cfg,
agentId,
workspaceDir,
@@ -1158,6 +1110,63 @@ export async function collectRuntimeToolSchemaFindings(
model,
}),
);
if (!shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true })) {
return;
}
if (
!bundleRuntimeByWorkspace.has(workspaceDir) &&
!bundleRuntimeLoadErrorsByWorkspace.has(workspaceDir)
) {
try {
bundleRuntimeByWorkspace.set(
workspaceDir,
await createBundleMcpToolRuntime({
workspaceDir,
cfg,
}),
);
} catch (error) {
bundleRuntimeLoadErrorsByWorkspace.set(
workspaceDir,
bundleMcpRuntimeLoadFailureFinding(error),
);
}
}
const bundleRuntimeLoadError = bundleRuntimeLoadErrorsByWorkspace.get(workspaceDir);
if (bundleRuntimeLoadError) {
if (!reportedBundleRuntimeLoadErrors.has(workspaceDir)) {
findings.push(bundleRuntimeLoadError);
reportedBundleRuntimeLoadErrors.add(workspaceDir);
}
return;
}
const bundleRuntime = bundleRuntimeByWorkspace.get(workspaceDir);
if (bundleRuntime) {
if (bundleRuntime.diagnostics && bundleRuntime.diagnostics.length > 0) {
const policyActiveDiagnostics = filterPolicyActiveBundleMcpDiagnostics({
diagnostics: bundleRuntime.diagnostics,
cfg,
agentId,
modelRef,
});
findings.push(...policyActiveDiagnostics.map(bundleMcpRuntimeDiagnosticFinding));
}
findings.push(
...collectBundleMcpRuntimeToolSchemaFindings({
bundleRuntime,
cfg,
agentId,
workspaceDir,
modelRef,
model,
}),
);
}
};
if (options?.runWithPluginMetadataSnapshot) {
await options.runWithPluginMetadataSnapshot({ config: cfg, workspaceDir }, collectForAgent);
} else {
await collectForAgent();
}
}
} finally {
+34 -3
View File
@@ -40,6 +40,7 @@ import type { CronJob } from "../cron/types.js";
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import type { PluginMetadataSnapshotScopeRunner } from "../plugins/current-plugin-metadata-snapshot.js";
import { getSkippedExecRefStaticError } from "../secrets/exec-resolution-policy.js";
import type { SkillStatusEntry } from "../skills/discovery/status.js";
import { resolveSkillWorkshopConfig } from "../skills/workshop/config.js";
@@ -128,7 +129,15 @@ async function collectRuntimeToolSchemaFindingsWithRuntime(
ctx: HealthCheckContext,
): Promise<readonly HealthFinding[]> {
const runtime = await loadDoctorCoreChecksRuntimeModule();
return runtime.collectRuntimeToolSchemaFindings(ctx.cfg);
const runWithPluginMetadataSnapshot = (
ctx as HealthCheckContext & {
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
}
).runWithPluginMetadataSnapshot;
return runtime.collectRuntimeToolSchemaFindings(
ctx.cfg,
runWithPluginMetadataSnapshot ? { runWithPluginMetadataSnapshot } : undefined,
);
}
async function collectProviderCatalogProjectionFindingsWithRuntime(
@@ -1032,6 +1041,28 @@ const browserCheck: HealthCheck = {
function createSkillsReadinessCheck(
deps: CoreHealthCheckDeps,
): HealthCheck & { readonly defaultEnabled: false } {
const detectUnavailableSkills = async (
ctx: HealthCheckContext | HealthRepairContext,
): Promise<readonly SkillStatusEntry[]> => {
const runWithPluginMetadataSnapshot = (
ctx as (HealthCheckContext | HealthRepairContext) & {
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
}
).runWithPluginMetadataSnapshot;
const detect = () => deps.detectUnavailableSkills(ctx.cfg);
if (!runWithPluginMetadataSnapshot) {
return await detect();
}
const defaultAgentId = resolveDefaultAgentId(ctx.cfg);
return await runWithPluginMetadataSnapshot(
{
config: ctx.cfg,
workspaceDir: resolveAgentWorkspaceDir(ctx.cfg, defaultAgentId),
},
detect,
);
};
return {
id: "core/doctor/skills-readiness",
kind: "core",
@@ -1040,14 +1071,14 @@ function createSkillsReadinessCheck(
defaultEnabled: false,
async detect(ctx, scope) {
const unavailable = filterUnavailableSkillsForScope(
await deps.detectUnavailableSkills(ctx.cfg),
await detectUnavailableSkills(ctx),
scope?.paths,
);
return unavailable.map(unavailableSkillToFinding);
},
async repair(ctx, findings) {
const unavailable = filterUnavailableSkillsForScope(
await deps.detectUnavailableSkills(ctx.cfg),
await detectUnavailableSkills(ctx),
findings.map((finding) => finding.path),
);
if (unavailable.length === 0) {
+32 -15
View File
@@ -1,9 +1,24 @@
import type { DoctorHealthFlowContext } from "./doctor-health-contribution-types.js";
import type {
DoctorHealthCheckContext,
DoctorHealthFlowContext,
} from "./doctor-health-contribution-types.js";
import { renderStructuredHealthFindings } from "./doctor-health-contribution.js";
import type { HealthCheck, HealthFinding } from "./health-checks.js";
const loadHealthCheckRegistryModule = async () => await import("./health-check-registry.js");
function withDoctorHealthCheckFacts<T extends object>(
ctx: DoctorHealthFlowContext,
input: T,
): T & Pick<DoctorHealthCheckContext, "runWithPluginMetadataSnapshot"> {
return {
...input,
...(ctx.runWithPluginMetadataSnapshot
? { runWithPluginMetadataSnapshot: ctx.runWithPluginMetadataSnapshot }
: {}),
};
}
export async function runStructuredHealthRepairs(
ctx: DoctorHealthFlowContext,
resolveCoreChecks: () => Promise<readonly HealthCheck[]>,
@@ -22,13 +37,13 @@ export async function runStructuredHealthRepairs(
registerBundledHealthChecks({ cfg: ctx.cfg, cwd: workspaceDir });
const checks = listExtensionHealthChecksForDoctor(await resolveCoreChecks());
const result = await runDoctorHealthRepairs(
{
mode: "fix",
withDoctorHealthCheckFacts(ctx, {
mode: "fix" as const,
runtime: ctx.runtime,
cfg: ctx.cfg,
cwd: workspaceDir,
configPath: ctx.configPath,
},
}),
{ checks },
);
ctx.cfg = result.config;
@@ -61,14 +76,14 @@ export async function runCoreContributionHealth(
const workspaceDir = resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg));
const dryRun = !ctx.prompter.shouldRepair;
const result = await runDoctorHealthRepairs(
{
mode: "fix",
withDoctorHealthCheckFacts(ctx, {
mode: "fix" as const,
runtime: ctx.runtime,
cfg: ctx.cfg,
cwd: workspaceDir,
configPath: ctx.configPath,
dryRun,
},
}),
{ checks, dryRun },
);
ctx.cfg = result.config;
@@ -112,14 +127,16 @@ export async function runCoreHealthFindingNote(
if (!check) {
return;
}
const findings = await check.detect({
mode: "doctor",
runtime: ctx.runtime,
cfg: ctx.cfg,
cwd: resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg)),
configPath: ctx.configPath,
allowExecSecretRefs: ctx.options.allowExec === true,
});
const findings = await check.detect(
withDoctorHealthCheckFacts(ctx, {
mode: "doctor" as const,
runtime: ctx.runtime,
cfg: ctx.cfg,
cwd: resolveAgentWorkspaceDir(ctx.cfg, resolveDefaultAgentId(ctx.cfg)),
configPath: ctx.configPath,
allowExecSecretRefs: ctx.options.allowExec === true,
}),
);
if (findings.length === 0) {
return;
}
@@ -8,21 +8,28 @@ const loadDoctorStateIntegrityModule = async () =>
export async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairLegacyPluginManifestContracts } =
await import("../commands/doctor-plugin-manifests.js");
await maybeRepairLegacyPluginManifestContracts({
const pluginInventoryChanged = await maybeRepairLegacyPluginManifestContracts({
config: ctx.cfg,
env: process.env,
runtime: ctx.runtime,
prompter: ctx.prompter,
});
if (pluginInventoryChanged) {
ctx.invalidatePluginMetadataSnapshot?.();
}
}
export async function runPluginRegistryHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairPluginRegistryState } = await import("../commands/doctor-plugin-registry.js");
ctx.cfg = await maybeRepairPluginRegistryState({
const result = await maybeRepairPluginRegistryState({
config: ctx.cfg,
env: process.env,
prompter: ctx.prompter,
});
ctx.cfg = result.config;
if (result.pluginInventoryChanged) {
ctx.invalidatePluginMetadataSnapshot?.();
}
}
export async function runReleaseConfiguredPluginInstallsHealth(
@@ -40,6 +47,9 @@ export async function runReleaseConfiguredPluginInstallsHealth(
env: ctx.env ?? process.env,
touchedVersion: ctx.configResult.sourceLastTouchedVersion ?? ctx.cfg.meta?.lastTouchedVersion,
});
if (result.pluginInventoryChanged) {
ctx.invalidatePluginMetadataSnapshot?.();
}
if (result.postInstallDoctorResult) {
ctx.postInstallDoctorResult = result.postInstallDoctorResult;
}
@@ -23,6 +23,9 @@ export async function runActiveToolSchemaWarningsHealth(
const warnings = await collectActiveToolSchemaProjectionWarnings({
cfg: ctx.cfg,
env: ctx.env ?? process.env,
...(ctx.runWithPluginMetadataSnapshot
? { runWithPluginMetadataSnapshot: ctx.runWithPluginMetadataSnapshot }
: {}),
});
if (warnings.length === 0) {
return;
@@ -107,12 +110,23 @@ export async function runWorkspaceStatusHealth(ctx: DoctorHealthFlowContext): Pr
options: ctx.options,
});
const { noteWorkspaceStatus } = await import("../commands/doctor-workspace-status.js");
noteWorkspaceStatus(ctx.cfg, { pluginVersionDrift });
noteWorkspaceStatus(ctx.cfg, {
pluginVersionDrift,
...(ctx.runWithPluginMetadataSnapshot
? { runWithPluginMetadataSnapshot: ctx.runWithPluginMetadataSnapshot }
: {}),
});
}
export async function runSkillsHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { maybeRepairSkillReadiness } = await import("../commands/doctor-skills.js");
ctx.cfg = await maybeRepairSkillReadiness({ cfg: ctx.cfg, prompter: ctx.prompter });
ctx.cfg = await maybeRepairSkillReadiness({
cfg: ctx.cfg,
prompter: ctx.prompter,
...(ctx.runWithPluginMetadataSnapshot
? { runWithPluginMetadataSnapshot: ctx.runWithPluginMetadataSnapshot }
: {}),
});
}
export async function runBootstrapSizeHealth(ctx: DoctorHealthFlowContext): Promise<void> {
+11 -1
View File
@@ -3,9 +3,10 @@ import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { buildGatewayConnectionDetails } from "../gateway/call.js";
import type { UpdatePostInstallDoctorResult } from "../infra/update-doctor-result.js";
import type { PluginMetadataSnapshotScopeRunner } from "../plugins/current-plugin-metadata-snapshot.js";
import type { RuntimeEnv } from "../runtime.js";
import type { HealthCheckInput, RunnableHealthCheck } from "./health-check-runner-types.js";
import type { HealthCheck } from "./health-checks.js";
import type { HealthCheck, HealthCheckContext } from "./health-checks.js";
import type { FlowContribution } from "./types.js";
type DoctorConfigResult = {
@@ -23,6 +24,8 @@ type DoctorConfigResult = {
blockedCodexModelIdentities?: readonly string[];
/** Ephemeral doctor-only auth rename plan; never part of persisted config. */
openAICodexAuthProfileIdMap?: ReadonlyMap<string, string>;
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
invalidatePluginMetadataSnapshot?: () => void;
};
export type DoctorHealthFlowContext = {
@@ -48,6 +51,13 @@ export type DoctorHealthFlowContext = {
gatewayStatus?: import("../status/types.js").StatusSummary;
gatewayMemoryProbe?: Awaited<ReturnType<typeof probeGatewayMemoryStatus>>;
postInstallDoctorResult?: UpdatePostInstallDoctorResult;
runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
invalidatePluginMetadataSnapshot?: () => void;
};
/** Internal facts carried through Doctor detect/repair/validate passes without widening the SDK. */
export type DoctorHealthCheckContext = HealthCheckContext & {
readonly runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner;
};
export type DoctorHealthContribution = FlowContribution & {
@@ -32,6 +32,7 @@ import {
runWorkspaceSuggestionsHealth,
} from "./doctor-health-contribution-runners.workspace.js";
import type {
DoctorHealthCheckContext,
DoctorHealthContribution,
DoctorHealthFlowContext,
} from "./doctor-health-contribution-types.js";
@@ -184,7 +185,12 @@ export function resolveFinalDoctorHealthContributions(params: {
cfg: ctx.cfg,
options: { nonInteractive: true, allowExec: ctx.allowExecSecretRefs === true },
});
return collectWorkspaceStatusHealthFindings(ctx.cfg, { pluginVersionDrift });
const runWithPluginMetadataSnapshot = (ctx as DoctorHealthCheckContext)
.runWithPluginMetadataSnapshot;
return collectWorkspaceStatusHealthFindings(ctx.cfg, {
pluginVersionDrift,
...(runWithPluginMetadataSnapshot ? { runWithPluginMetadataSnapshot } : {}),
});
},
},
run: runWorkspaceStatusHealth,
@@ -62,11 +62,6 @@ export function resolveInitialDoctorHealthContributions(params: {
label: "Write config migrations",
run: runInitialConfigWriteHealth,
}),
createDoctorHealthContribution({
id: "doctor:active-tool-schema-warnings",
label: "Active tool schema warnings",
run: runActiveToolSchemaWarningsHealth,
}),
createDoctorHealthContribution({
id: "doctor:gateway-config",
label: "Gateway config",
@@ -253,6 +248,13 @@ export function resolveInitialDoctorHealthContributions(params: {
},
run: runPluginRegistryHealth,
}),
// Runtime tool discovery must follow plugin metadata repair; running it earlier
// scans each workspace again after the authoritative generation changes.
createDoctorHealthContribution({
id: "doctor:active-tool-schema-warnings",
label: "Active tool schema warnings",
run: runActiveToolSchemaWarningsHealth,
}),
createDoctorHealthContribution({
id: "doctor:ui-protocol-freshness",
label: "UI protocol freshness",
@@ -846,6 +846,22 @@ describe("doctor health contributions", () => {
);
});
it("invalidates retained plugin metadata after rewriting a legacy manifest", async () => {
mocks.maybeRepairLegacyPluginManifestContracts.mockResolvedValueOnce(true);
const invalidatePluginMetadataSnapshot = vi.fn();
const contribution = requireDoctorContribution("doctor:legacy-plugin-manifests");
const ctx = {
cfg: {},
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
prompter: buildDoctorPrompter(true),
invalidatePluginMetadataSnapshot,
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
expect(invalidatePluginMetadataSnapshot).toHaveBeenCalledOnce();
});
it("runs release configured plugin install repair before plugin registry and final config writes", () => {
const ids = resolveDoctorHealthContributions().map((entry) => entry.id);
@@ -855,6 +871,9 @@ describe("doctor health contributions", () => {
ids.indexOf("doctor:plugin-registry"),
);
expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(ids.indexOf("doctor:write-config"));
expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(
ids.indexOf("doctor:active-tool-schema-warnings"),
);
});
it("repairs canonical session rows before downstream agent-state checks", () => {
@@ -1109,7 +1128,9 @@ describe("doctor health contributions", () => {
changes: ["Installed configured plugin matrix."],
warnings: [],
touchedConfig: true,
pluginInventoryChanged: true,
});
const invalidatePluginMetadataSnapshot = vi.fn();
const contribution = requireDoctorContribution("doctor:release-configured-plugin-installs");
const ctx = {
cfg: {},
@@ -1117,6 +1138,7 @@ describe("doctor health contributions", () => {
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
env: {},
invalidatePluginMetadataSnapshot,
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(ctx);
@@ -1131,6 +1153,7 @@ describe("doctor health contributions", () => {
"Doctor changes",
);
expect(ctx.cfg.meta?.lastTouchedVersion).toBe("2026.5.2-test");
expect(invalidatePluginMetadataSnapshot).toHaveBeenCalledOnce();
});
it("keeps legacy parent writable release repairs old-parent-readable", async () => {
@@ -1681,6 +1704,7 @@ describe("doctor health contributions", () => {
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
const ctx = {
cfg,
configResult: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
@@ -1721,6 +1745,7 @@ describe("doctor health contributions", () => {
}));
const ctx = {
cfg: {},
configResult: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(false),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
@@ -1761,6 +1786,7 @@ describe("doctor health contributions", () => {
}));
const ctx = {
cfg: {},
configResult: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
@@ -1782,6 +1808,7 @@ describe("doctor health contributions", () => {
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
const ctx = {
cfg,
configResult: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
@@ -1813,6 +1840,7 @@ describe("doctor health contributions", () => {
});
const ctx = {
cfg: {},
configResult: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
+18 -1
View File
@@ -407,8 +407,25 @@ export async function resolveDoctorContributionHealthChecks(): Promise<readonly
}
export async function runDoctorHealthContributions(ctx: DoctorHealthFlowContext): Promise<void> {
const runWithPluginMetadataSnapshot = ctx.runWithPluginMetadataSnapshot;
if (!runWithPluginMetadataSnapshot) {
for (const contribution of resolveDoctorHealthContributions()) {
await contribution.run(ctx);
}
return;
}
const { resolveAgentWorkspaceDir, resolveDefaultAgentId } =
await import("../agents/agent-scope.js");
for (const contribution of resolveDoctorHealthContributions()) {
await contribution.run(ctx);
const workspaceDir = resolveAgentWorkspaceDir(
ctx.cfg,
resolveDefaultAgentId(ctx.cfg),
ctx.env ?? process.env,
);
await runWithPluginMetadataSnapshot({ config: ctx.cfg, workspaceDir }, () =>
contribution.run(ctx),
);
}
}
+2
View File
@@ -109,6 +109,8 @@ export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions
sourceConfigValid: configResult.sourceConfigValid ?? true,
configPath: configResult.path ?? CONFIG_PATH,
stateDirExistedAtStart,
runWithPluginMetadataSnapshot: configResult.runWithPluginMetadataSnapshot,
invalidatePluginMetadataSnapshot: configResult.invalidatePluginMetadataSnapshot,
};
const { runDoctorHealthContributions } = await import("./doctor-health-contributions.js");
await runDoctorHealthContributions(ctx);
@@ -8,6 +8,7 @@ import {
getCurrentPluginMetadataSnapshot,
installTemporaryCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
withPluginMetadataSnapshotScope,
} from "./current-plugin-metadata-snapshot.js";
import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
@@ -112,6 +113,177 @@ describe("current plugin metadata snapshot", () => {
).toBeUndefined();
});
it("keeps owner-prepared metadata scoped to nested async work", async () => {
const globalConfig = { plugins: { allow: ["global"] } };
const scopedConfig = { plugins: { allow: ["scoped"] } };
const globalSnapshot = createSnapshot({
config: globalConfig,
workspaceDir: "/workspace/global",
});
const scopedSnapshot = createSnapshot({
config: scopedConfig,
workspaceDir: "/workspace/scoped",
});
setCurrentPluginMetadataSnapshot(globalSnapshot, { config: globalConfig });
await withPluginMetadataSnapshotScope(
scopedSnapshot,
async () => {
await Promise.resolve();
expect(
getCurrentPluginMetadataSnapshot({
config: scopedConfig,
workspaceDir: "/workspace/scoped",
}),
).toBe(scopedSnapshot);
expect(
getCurrentPluginMetadataSnapshot({
config: globalConfig,
workspaceDir: "/workspace/global",
}),
).toBe(globalSnapshot);
},
{ config: scopedConfig },
);
expect(
getCurrentPluginMetadataSnapshot({
config: scopedConfig,
workspaceDir: "/workspace/scoped",
}),
).toBeUndefined();
expect(
getCurrentPluginMetadataSnapshot({
config: globalConfig,
workspaceDir: "/workspace/global",
}),
).toBe(globalSnapshot);
});
it("lets configless nested readers inherit explicit owner discovery context", () => {
const config = {
plugins: {
allow: ["scoped"],
load: { paths: ["/plugins/scoped"] },
},
};
const snapshot = createSnapshot({ config, workspaceDir: "/workspace/scoped" });
setCurrentPluginMetadataSnapshot(undefined);
withPluginMetadataSnapshotScope(
snapshot,
() => {
expect(
getCurrentPluginMetadataSnapshot({
allowWorkspaceScopedSnapshot: true,
requireDefaultDiscoveryContext: true,
}),
).toBe(snapshot);
},
{ config },
);
expect(
getCurrentPluginMetadataSnapshot({
allowWorkspaceScopedSnapshot: true,
requireDefaultDiscoveryContext: true,
}),
).toBeUndefined();
});
it("isolates concurrent owner-prepared metadata scopes", async () => {
const firstConfig = { plugins: { allow: ["first"] } };
const secondConfig = { plugins: { allow: ["second"] } };
const first = createSnapshot({ config: firstConfig, workspaceDir: "/workspace/first" });
const second = createSnapshot({ config: secondConfig, workspaceDir: "/workspace/second" });
const [firstResult, secondResult] = await Promise.all([
withPluginMetadataSnapshotScope(
first,
async () => {
await Promise.resolve();
return getCurrentPluginMetadataSnapshot({
config: firstConfig,
workspaceDir: "/workspace/first",
});
},
{ config: firstConfig },
),
withPluginMetadataSnapshotScope(
second,
async () => {
await Promise.resolve();
return getCurrentPluginMetadataSnapshot({
config: secondConfig,
workspaceDir: "/workspace/second",
});
},
{ config: secondConfig },
),
]);
expect(firstResult).toBe(first);
expect(secondResult).toBe(second);
});
it("falls through nested scopes and restores the parent after rejection", async () => {
const outerConfig = { plugins: { allow: ["outer"] } };
const innerConfig = { plugins: { allow: ["inner"] } };
const outer = createSnapshot({ config: outerConfig, workspaceDir: "/workspace/outer" });
const inner = createSnapshot({ config: innerConfig, workspaceDir: "/workspace/inner" });
setCurrentPluginMetadataSnapshot(undefined);
await withPluginMetadataSnapshotScope(
outer,
async () => {
await expect(
withPluginMetadataSnapshotScope(
inner,
async () => {
expect(
getCurrentPluginMetadataSnapshot({
config: outerConfig,
workspaceDir: "/workspace/outer",
}),
).toBe(outer);
throw new Error("scope failed");
},
{ config: innerConfig },
),
).rejects.toThrow("scope failed");
expect(
getCurrentPluginMetadataSnapshot({
config: outerConfig,
workspaceDir: "/workspace/outer",
}),
).toBe(outer);
},
{ config: outerConfig },
);
});
it("supports compatible config identities within an owner-prepared scope", () => {
const sourceConfig = { plugins: { allow: ["source"] } };
const runtimeConfig = { plugins: { allow: ["runtime"] } };
const snapshot = createSnapshot({ config: sourceConfig, workspaceDir: "/workspace" });
withPluginMetadataSnapshotScope(
snapshot,
() => {
expect(
getCurrentPluginMetadataSnapshot({
config: runtimeConfig,
workspaceDir: "/workspace",
}),
).toBe(snapshot);
},
{
config: sourceConfig,
compatibleConfigs: [runtimeConfig],
},
);
});
it("rejects a workspace-scoped snapshot when the caller does not provide workspace scope", () => {
const config = { plugins: { allow: ["demo"] } };
const snapshot = createSnapshot({ config, workspaceDir: "/workspace/a" });
+127 -25
View File
@@ -1,5 +1,7 @@
/** Tracks the current plugin metadata snapshot for control-plane lookups. */
import { AsyncLocalStorage } from "node:async_hooks";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import {
currentPluginMetadataConfigIdentityCache,
getCurrentPluginMetadataSnapshotState,
@@ -41,10 +43,46 @@ type TemporaryPluginMetadataSnapshotLease = {
release: () => boolean;
};
type CurrentPluginMetadataSnapshotParams = {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
allowScopedSnapshot?: boolean;
pluginIds?: readonly string[];
pluginIdScope?: PluginMetadataSnapshotPluginIdScope;
workspaceDir?: string;
allowWorkspaceScopedSnapshot?: boolean;
requireDefaultDiscoveryContext?: boolean;
};
type PluginMetadataSnapshotCandidate = {
snapshot: PluginMetadataSnapshot | undefined;
configFingerprint: string | undefined;
compatiblePolicyHashes?: readonly string[];
compatibleConfigFingerprints?: readonly string[];
hasConfigIdentity?: (config: OpenClawConfig) => boolean;
};
type ScopedPluginMetadataSnapshot = PluginMetadataSnapshotCandidate & {
parent?: ScopedPluginMetadataSnapshot;
};
export type PluginMetadataSnapshotScopeRunner = <T>(
params: {
config: OpenClawConfig;
workspaceDir?: string;
},
run: () => T,
) => T;
let activeTemporaryPluginMetadataSnapshotLease:
| TemporaryPluginMetadataSnapshotLeaseState
| undefined;
const SCOPED_PLUGIN_METADATA_SNAPSHOT_KEY = Symbol.for("openclaw.scopedPluginMetadataSnapshot");
const scopedPluginMetadataSnapshot = resolveGlobalSingleton<
AsyncLocalStorage<ScopedPluginMetadataSnapshot>
>(SCOPED_PLUGIN_METADATA_SNAPSHOT_KEY, () => new AsyncLocalStorage());
function resolvePluginMetadataControlPlaneFingerprint(
config?: OpenClawConfig,
options: Omit<ResolvePluginControlPlaneContextParams, "config"> = {},
@@ -212,25 +250,61 @@ export function installTemporaryCurrentPluginMetadataSnapshot(
};
}
export function getCurrentPluginMetadataSnapshot(
params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
allowScopedSnapshot?: boolean;
pluginIds?: readonly string[];
pluginIdScope?: PluginMetadataSnapshotPluginIdScope;
workspaceDir?: string;
allowWorkspaceScopedSnapshot?: boolean;
requireDefaultDiscoveryContext?: boolean;
} = {},
/** Carries one owner-prepared metadata generation through nested async plugin lookups. */
export function withPluginMetadataSnapshotScope<T>(
snapshot: PluginMetadataSnapshot,
run: () => T,
options: CurrentPluginMetadataSnapshotOptions = {},
): T {
const workspaceDir = options.workspaceDir ?? snapshot.workspaceDir;
const compatiblePolicyHashes = options.compatibleConfigs?.map((config) =>
resolveInstalledPluginIndexPolicyHash(config),
);
const compatibleConfigFingerprints = options.compatibleConfigs?.map((config, index) =>
resolvePluginMetadataControlPlaneFingerprint(config, {
env: options.env,
index: snapshot.index,
policyHash: compatiblePolicyHashes?.[index],
workspaceDir,
}),
);
const configFingerprint = options.config
? resolvePluginMetadataControlPlaneFingerprint(options.config, {
env: options.env,
index: snapshot.index,
policyHash: snapshot.policyHash,
workspaceDir,
})
: snapshot.configFingerprint;
const configIdentities = new WeakSet<OpenClawConfig>();
if (options.config) {
const policyHash = resolveInstalledPluginIndexPolicyHash(options.config);
if (policyHash === snapshot.policyHash || compatiblePolicyHashes?.includes(policyHash)) {
configIdentities.add(options.config);
}
}
for (const config of options.compatibleConfigs ?? []) {
configIdentities.add(config);
}
return scopedPluginMetadataSnapshot.run(
{
snapshot,
configFingerprint,
compatiblePolicyHashes,
compatibleConfigFingerprints,
hasConfigIdentity: (config) => configIdentities.has(config),
parent: scopedPluginMetadataSnapshot.getStore(),
},
run,
);
}
function resolveCompatiblePluginMetadataSnapshot(
candidate: PluginMetadataSnapshotCandidate,
params: CurrentPluginMetadataSnapshotParams,
options: { scopedOwnerContext?: boolean } = {},
): PluginMetadataSnapshot | undefined {
const {
snapshot: rawSnapshot,
configFingerprint,
compatiblePolicyHashes,
compatibleConfigFingerprints,
} = getCurrentPluginMetadataSnapshotState();
const snapshot = rawSnapshot as PluginMetadataSnapshot | undefined;
const snapshot = candidate.snapshot;
if (!snapshot) {
return undefined;
}
@@ -265,7 +339,7 @@ export function getCurrentPluginMetadataSnapshot(
return undefined;
}
const canReuseCachedConfig = Boolean(
params.config && currentPluginMetadataConfigIdentityCache.has(params.config),
params.config && candidate.hasConfigIdentity?.(params.config),
);
if (canReuseCachedConfig && params.requireDefaultDiscoveryContext !== true) {
return snapshot;
@@ -275,7 +349,7 @@ export function getCurrentPluginMetadataSnapshot(
? resolveInstalledPluginIndexPolicyHash(params.config)
: undefined;
if (requestedPolicyHash && snapshot.policyHash !== requestedPolicyHash) {
if (!compatiblePolicyHashes?.includes(requestedPolicyHash)) {
if (!candidate.compatiblePolicyHashes?.includes(requestedPolicyHash)) {
return undefined;
}
}
@@ -287,14 +361,14 @@ export function getCurrentPluginMetadataSnapshot(
workspaceDir: requestedWorkspaceDir,
});
const fingerprintMatches =
configFingerprint === requestedConfigFingerprint ||
candidate.configFingerprint === requestedConfigFingerprint ||
snapshot.configFingerprint === requestedConfigFingerprint ||
Boolean(compatibleConfigFingerprints?.includes(requestedConfigFingerprint));
Boolean(candidate.compatibleConfigFingerprints?.includes(requestedConfigFingerprint));
if (!fingerprintMatches) {
return undefined;
}
}
if (params.requireDefaultDiscoveryContext === true) {
if (params.requireDefaultDiscoveryContext === true && options.scopedOwnerContext !== true) {
const defaultDiscoveryConfigFingerprint = resolvePluginMetadataControlPlaneFingerprint(
{},
{
@@ -305,12 +379,40 @@ export function getCurrentPluginMetadataSnapshot(
},
);
const fingerprintMatches =
configFingerprint === defaultDiscoveryConfigFingerprint ||
candidate.configFingerprint === defaultDiscoveryConfigFingerprint ||
snapshot.configFingerprint === defaultDiscoveryConfigFingerprint ||
Boolean(compatibleConfigFingerprints?.includes(defaultDiscoveryConfigFingerprint));
Boolean(candidate.compatibleConfigFingerprints?.includes(defaultDiscoveryConfigFingerprint));
if (!fingerprintMatches) {
return undefined;
}
}
return snapshot;
}
export function getCurrentPluginMetadataSnapshot(
params: CurrentPluginMetadataSnapshotParams = {},
): PluginMetadataSnapshot | undefined {
for (let scoped = scopedPluginMetadataSnapshot.getStore(); scoped; scoped = scoped.parent) {
// An explicit async owner scope is the discovery context for nested configless readers.
// Global snapshots still require proof that they match the default discovery context.
const compatibleScoped = resolveCompatiblePluginMetadataSnapshot(scoped, params, {
scopedOwnerContext: true,
});
if (compatibleScoped) {
return compatibleScoped;
}
}
const { snapshot, configFingerprint, compatiblePolicyHashes, compatibleConfigFingerprints } =
getCurrentPluginMetadataSnapshotState();
return resolveCompatiblePluginMetadataSnapshot(
{
snapshot: snapshot as PluginMetadataSnapshot | undefined,
configFingerprint,
compatiblePolicyHashes,
compatibleConfigFingerprints,
hasConfigIdentity: (config) => currentPluginMetadataConfigIdentityCache.has(config),
},
params,
);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js";
import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import {
loadPluginRegistrySnapshotWithMetadata,
type PluginRegistrySnapshotDiagnostic,
@@ -153,7 +153,7 @@ export function buildPluginRegistrySnapshotReport(
}),
{ surface: "status" },
);
const metadataSnapshot = loadPluginMetadataSnapshot({
const metadataSnapshot = resolvePluginMetadataSnapshot({
index: result.snapshot,
config,
env,