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