From 80da61668eb6445d6430a8513301c222d820c9c0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 5 Aug 2026 19:06:08 +0800 Subject: [PATCH] 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 --- src/commands/doctor-config-flow.test.ts | 65 ++++ src/commands/doctor-config-flow.ts | 158 +++++--- .../doctor-config-preflight-plugin-index.ts | 45 +++ .../doctor-config-preflight.process.test.ts | 362 ++++++++++++++++++ ...ig-preflight.state-migration-input.test.ts | 18 +- ...r-config-preflight.state-migration.test.ts | 12 + src/commands/doctor-config-preflight.ts | 126 +++--- .../doctor-legacy-config.migrations.test.ts | 39 ++ src/commands/doctor-plugin-manifests.test.ts | 3 +- src/commands/doctor-plugin-manifests.ts | 7 +- src/commands/doctor-plugin-registry.test.ts | 16 +- src/commands/doctor-plugin-registry.ts | 26 +- src/commands/doctor-skills.ts | 17 +- src/commands/doctor-workspace-status.ts | 114 +++--- src/commands/doctor/repair-sequencing.test.ts | 158 +++++++- src/commands/doctor/repair-sequencing.ts | 152 ++++++-- .../shared/active-tool-schema-warnings.ts | 192 +++++----- .../doctor/shared/legacy-config-issues.ts | 26 +- ...issing-configured-plugin-install.repair.ts | 4 + .../missing-configured-plugin-install.test.ts | 5 + .../shared/plugin-metadata-snapshot-scope.ts | 102 +++++ .../shared/plugin-registry-migration.ts | 22 +- .../doctor/shared/preview-warnings.test.ts | 26 +- .../doctor/shared/preview-warnings.ts | 12 +- ...release-configured-plugin-installs.test.ts | 2 + .../release-configured-plugin-installs.ts | 3 + .../shared/stale-agent-model-ref-repair.ts | 16 +- src/flows/doctor-core-checks.runtime.ts | 143 +++---- src/flows/doctor-core-checks.ts | 37 +- src/flows/doctor-health-contribution-core.ts | 47 ++- ...octor-health-contribution-runners.state.ts | 14 +- ...r-health-contribution-runners.workspace.ts | 18 +- src/flows/doctor-health-contribution-types.ts | 12 +- .../doctor-health-contributions-final.ts | 8 +- .../doctor-health-contributions-initial.ts | 12 +- src/flows/doctor-health-contributions.test.ts | 28 ++ src/flows/doctor-health-contributions.ts | 19 +- src/flows/doctor-health.ts | 2 + .../current-plugin-metadata-snapshot.test.ts | 172 +++++++++ .../current-plugin-metadata-snapshot.ts | 152 ++++++-- src/plugins/status-snapshot.ts | 4 +- 41 files changed, 1941 insertions(+), 455 deletions(-) create mode 100644 src/commands/doctor/shared/plugin-metadata-snapshot-scope.ts diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index f9dc77f41b9d..635c7d80b829 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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()); 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[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: { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 4ac77330dd67..4e15214d6bdc 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -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 = (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, }; } diff --git a/src/commands/doctor-config-preflight-plugin-index.ts b/src/commands/doctor-config-preflight-plugin-index.ts index a2ab131f07b9..f71f8d8acaae 100644 --- a/src/commands/doctor-config-preflight-plugin-index.ts +++ b/src/commands/doctor-config-preflight-plugin-index.ts @@ -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 { + 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 { diff --git a/src/commands/doctor-config-preflight.process.test.ts b/src/commands/doctor-config-preflight.process.test.ts index fef64597ca57..c3dab7ae425c 100644 --- a/src/commands/doctor-config-preflight.process.test.ts +++ b/src/commands/doctor-config-preflight.process.test.ts @@ -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); }); diff --git a/src/commands/doctor-config-preflight.state-migration-input.test.ts b/src/commands/doctor-config-preflight.state-migration-input.test.ts index 8ffb7b297f5a..c5149e13cba9 100644 --- a/src/commands/doctor-config-preflight.state-migration-input.test.ts +++ b/src/commands/doctor-config-preflight.state-migration-input.test.ts @@ -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, })); diff --git a/src/commands/doctor-config-preflight.state-migration.test.ts b/src/commands/doctor-config-preflight.state-migration.test.ts index c4426141bb32..44b25e2ebf7f 100644 --- a/src/commands/doctor-config-preflight.state-migration.test.ts +++ b/src/commands/doctor-config-preflight.state-migration.test.ts @@ -143,6 +143,10 @@ const readConfigFileSnapshotWithPluginMetadata = vi.hoisted(() => })), ); const findDoctorLegacyConfigIssues = vi.hoisted(() => vi.fn((): LegacyConfigIssue[] => [])); +const addDoctorLegacyIssues = vi.hoisted(() => vi.fn((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"); diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 0306324d0f09..95f4e6d5942c 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -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 { export type DoctorConfigPreflightResult = { snapshot: Awaited>; baseConfig: OpenClawConfig; + pluginMetadataSnapshot?: PluginMetadataSnapshot; cronCodexRuntimePolicyTargets?: CronCodexRuntimePolicyTarget[]; }; -function collectDoctorLegacyIssues( - snapshot: Awaited>, -): LegacyConfigIssue[] { - if (!snapshot.exists) { - return []; - } - const resolvedRaw = snapshot.sourceConfig ?? snapshot.config ?? {}; - const sourceRaw = snapshot.parsed ?? resolvedRaw; - return findDoctorLegacyConfigIssues(resolvedRaw, sourceRaw); -} - -function addDoctorLegacyIssues( - snapshot: Awaited>, -): Awaited> { - 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; 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 { diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 1e4cb01dbdb4..609906132fbc 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -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( { diff --git a/src/commands/doctor-plugin-manifests.test.ts b/src/commands/doctor-plugin-manifests.test.ts index 25ad69a1aac4..001f6fb23761 100644 --- a/src/commands/doctor-plugin-manifests.test.ts +++ b/src/commands/doctor-plugin-manifests.test.ts @@ -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[]; diff --git a/src/commands/doctor-plugin-manifests.ts b/src/commands/doctor-plugin-manifests.ts index 2027c39d213f..c38d246b7765 100644 --- a/src/commands/doctor-plugin-manifests.ts +++ b/src/commands/doctor-plugin-manifests.ts @@ -182,7 +182,7 @@ export async function maybeRepairLegacyPluginManifestContracts(params: { runtime: RuntimeEnv; prompter: DoctorPrompter; note?: typeof note; -}): Promise { +}): Promise { 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; } diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index ceb3ca7ab43e..f603b934a68c 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -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"); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index 663c5c434017..3c57cb818dcd 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -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; }; +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 { +): Promise { 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 }; } diff --git a/src/commands/doctor-skills.ts b/src/commands/doctor-skills.ts index f8871947fd91..f75f3fa8e4c1 100644 --- a/src/commands/doctor-skills.ts +++ b/src/commands/doctor-skills.ts @@ -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 { 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 }) => ({ diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index 2115d61fa07a..7fbf719030dc 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -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); diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index a6a243006081..a1533fd35258 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -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[1]>, + ) => + repairStaleAgentModelRefsActual(cfg, { + ...options, + persistedProviderIdsByAgentId: new Map([["main", new Set()]]), + }), + ); + const pluginMetadataSnapshotState = { + current: staleSnapshot as unknown as PluginMetadataSnapshot, + }; + const scopedSnapshots: Array = []; + const runWithPluginMetadataSnapshot = ( + _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.'], diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 3f3fc82aa82a..fb7c70858126 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -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; + 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 = (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 } + : {}), }; } diff --git a/src/commands/doctor/shared/active-tool-schema-warnings.ts b/src/commands/doctor/shared/active-tool-schema-warnings.ts index 4ad210e9f580..cc8f4471d8d1 100644 --- a/src/commands/doctor/shared/active-tool-schema-warnings.ts +++ b/src/commands/doctor/shared/active-tool-schema-warnings.ts @@ -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 { 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; - 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 => { + 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; + 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; diff --git a/src/commands/doctor/shared/legacy-config-issues.ts b/src/commands/doctor/shared/legacy-config-issues.ts index ac98537bf78a..1fbe062c7930 100644 --- a/src/commands/doctor/shared/legacy-config-issues.ts +++ b/src/commands/doctor/shared/legacy-config-issues.ts @@ -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 { 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(); +} diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts b/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts index 8440667b2758..07b5d793973a 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.repair.ts @@ -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) => diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index bf306bc72288..cd586a8074f6 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -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, }); }); diff --git a/src/commands/doctor/shared/plugin-metadata-snapshot-scope.ts b/src/commands/doctor/shared/plugin-metadata-snapshot-scope.ts new file mode 100644 index 000000000000..d08c62f7f3ab --- /dev/null +++ b/src/commands/doctor/shared/plugin-metadata-snapshot-scope.ts @@ -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(); + 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(); + }, + }; +} diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts index 2c4ee95cb2e1..b3a99e153135 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -28,14 +28,19 @@ const DOCTOR_PLUGIN_ID_ALIASES: Readonly> = { 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, }; } } diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 35069caa38a1..9ab1fb50411b 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -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(); 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 = ( + _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 = [ diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 9eb4fb3dc2ed..08758045f1eb 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -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 { 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( diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index 58e18cadcb8c..35ab0f8d69be 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -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 () => { diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 7c28e94ad933..8a3990b971e2 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -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 } : {}), }; } diff --git a/src/commands/doctor/shared/stale-agent-model-ref-repair.ts b/src/commands/doctor/shared/stale-agent-model-ref-repair.ts index a2d4e26bcc66..4a6f62a08d0a 100644 --- a/src/commands/doctor/shared/stale-agent-model-ref-repair.ts +++ b/src/commands/doctor/shared/stale-agent-model-ref-repair.ts @@ -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; /** 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: [ diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts index 906c3632ec37..cf844f7d4b9f 100644 --- a/src/flows/doctor-core-checks.runtime.ts +++ b/src/flows/doctor-core-checks.runtime.ts @@ -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 { const findings: HealthFinding[] = []; const bundleRuntimeByWorkspace = new Map(); @@ -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 { diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index 98114e33338a..85922b964533 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -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 { 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 => { + 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) { diff --git a/src/flows/doctor-health-contribution-core.ts b/src/flows/doctor-health-contribution-core.ts index ded3cd30ffde..25bc898387bb 100644 --- a/src/flows/doctor-health-contribution-core.ts +++ b/src/flows/doctor-health-contribution-core.ts @@ -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( + ctx: DoctorHealthFlowContext, + input: T, +): T & Pick { + return { + ...input, + ...(ctx.runWithPluginMetadataSnapshot + ? { runWithPluginMetadataSnapshot: ctx.runWithPluginMetadataSnapshot } + : {}), + }; +} + export async function runStructuredHealthRepairs( ctx: DoctorHealthFlowContext, resolveCoreChecks: () => Promise, @@ -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; } diff --git a/src/flows/doctor-health-contribution-runners.state.ts b/src/flows/doctor-health-contribution-runners.state.ts index 0e7a06ec94ee..b3624494e839 100644 --- a/src/flows/doctor-health-contribution-runners.state.ts +++ b/src/flows/doctor-health-contribution-runners.state.ts @@ -8,21 +8,28 @@ const loadDoctorStateIntegrityModule = async () => export async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Promise { 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 { 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; } diff --git a/src/flows/doctor-health-contribution-runners.workspace.ts b/src/flows/doctor-health-contribution-runners.workspace.ts index 6a6408f70b0d..5ee4503fc10d 100644 --- a/src/flows/doctor-health-contribution-runners.workspace.ts +++ b/src/flows/doctor-health-contribution-runners.workspace.ts @@ -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 { 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 { diff --git a/src/flows/doctor-health-contribution-types.ts b/src/flows/doctor-health-contribution-types.ts index 54b8d4523cda..4ead7588c66a 100644 --- a/src/flows/doctor-health-contribution-types.ts +++ b/src/flows/doctor-health-contribution-types.ts @@ -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; + runWithPluginMetadataSnapshot?: PluginMetadataSnapshotScopeRunner; + invalidatePluginMetadataSnapshot?: () => void; }; export type DoctorHealthFlowContext = { @@ -48,6 +51,13 @@ export type DoctorHealthFlowContext = { gatewayStatus?: import("../status/types.js").StatusSummary; gatewayMemoryProbe?: Awaited>; 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 & { diff --git a/src/flows/doctor-health-contributions-final.ts b/src/flows/doctor-health-contributions-final.ts index 0c50648353e1..2d3f40467028 100644 --- a/src/flows/doctor-health-contributions-final.ts +++ b/src/flows/doctor-health-contributions-final.ts @@ -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, diff --git a/src/flows/doctor-health-contributions-initial.ts b/src/flows/doctor-health-contributions-initial.ts index 0158d064638e..04c24d09dfce 100644 --- a/src/flows/doctor-health-contributions-initial.ts +++ b/src/flows/doctor-health-contributions-initial.ts @@ -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", diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index c989c399a32d..6b954bb1879e 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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() }, diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 765172ee378b..f6ad678d86aa 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -407,8 +407,25 @@ export async function resolveDoctorContributionHealthChecks(): Promise { + 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), + ); } } diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index c4ee43f43ff9..a4d92045f5a2 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -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); diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index 6f90c781c410..e643758d378c 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -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" }); diff --git a/src/plugins/current-plugin-metadata-snapshot.ts b/src/plugins/current-plugin-metadata-snapshot.ts index 3dc427242ba9..66990897f946 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -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 = ( + 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 +>(SCOPED_PLUGIN_METADATA_SNAPSHOT_KEY, () => new AsyncLocalStorage()); + function resolvePluginMetadataControlPlaneFingerprint( config?: OpenClawConfig, options: Omit = {}, @@ -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( + 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(); + 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, + ); +} diff --git a/src/plugins/status-snapshot.ts b/src/plugins/status-snapshot.ts index f0142ecac86e..c05a2454cec4 100644 --- a/src/plugins/status-snapshot.ts +++ b/src/plugins/status-snapshot.ts @@ -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,