mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: gateway boots when a configured plugin payload is broken (#110239)
* fix: quarantine broken plugins during gateway startup * fix(plugins): preserve degraded boot on package read errors * fix(gateway): emit quarantine diagnostic once * fix(gateway): refresh plugin quarantine every boot * fix(gateway): harden plugin payload quarantine Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> * fix(ci): satisfy plugin quarantine checks Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> --------- Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -289,6 +289,13 @@ serves your channels, not only a wrapper or supervisor.
|
||||
| `OPENCLAW_NIX_MODE=1` blocks lifecycle commands | Confirm the install is managed by Nix | Change plugin selection in the Nix source instead of using plugin mutator commands |
|
||||
| Dependency import fails at runtime | Check whether the plugin was installed through npm/git/ClawHub or loaded from a local path | Run `openclaw plugins update <id>`, reinstall the source, or install local plugin dependencies yourself |
|
||||
|
||||
When an enabled managed plugin fails payload verification during Gateway
|
||||
startup, OpenClaw quarantines that exact installed plugin root for the boot and
|
||||
continues serving other plugins. `openclaw status --all`, `openclaw health`,
|
||||
and `openclaw doctor` report it as `configured-unavailable`. Fix or reinstall
|
||||
the plugin, then restart the Gateway. A healthy explicit `plugins.load.paths`
|
||||
override with the same plugin id is not quarantined by a stale broken install.
|
||||
|
||||
When stale plugin config still names a no-longer-discoverable channel plugin,
|
||||
config validation downgrades that channel key to a warning instead of a hard
|
||||
failure, so Gateway startup can still serve every other channel. Run
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Boot-local plugin payload verification without repair, install, or catalog imports.
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
||||
import {
|
||||
resolveTrustedSourceLinkedOfficialClawHubSpec,
|
||||
resolveTrustedSourceLinkedOfficialNpmSpec,
|
||||
} from "../../plugins/official-external-install-records.js";
|
||||
import {
|
||||
runPluginPayloadSmokeCheck,
|
||||
type PluginPayloadSmokeResult,
|
||||
} from "./plugin-payload-validation.js";
|
||||
|
||||
/** Runs the static payload check without repair, installs, or network access. */
|
||||
export async function runActivePluginPayloadSmokeCheck(params: {
|
||||
cfg: OpenClawConfig;
|
||||
records: Record<string, PluginInstallRecord>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<PluginPayloadSmokeResult> {
|
||||
return await runPluginPayloadSmokeCheck({
|
||||
records: filterRecordsToActive({ cfg: params.cfg, records: params.records }),
|
||||
env: params.env,
|
||||
});
|
||||
}
|
||||
|
||||
/** Selects the installed records covered by update/startup payload verification. */
|
||||
export function filterRecordsToActive(params: {
|
||||
cfg: OpenClawConfig;
|
||||
records: Record<string, PluginInstallRecord>;
|
||||
}): Record<string, PluginInstallRecord> {
|
||||
const normalizedPluginConfig = normalizePluginsConfig(params.cfg.plugins);
|
||||
const filtered: Record<string, PluginInstallRecord> = {};
|
||||
for (const [pluginId, record] of Object.entries(params.records)) {
|
||||
if (!record || typeof record !== "object") {
|
||||
continue;
|
||||
}
|
||||
const enableState = resolveEffectiveEnableState({
|
||||
id: pluginId,
|
||||
origin: "global",
|
||||
config: normalizedPluginConfig,
|
||||
rootConfig: params.cfg,
|
||||
});
|
||||
if (enableState.enabled) {
|
||||
filtered[pluginId] = record;
|
||||
continue;
|
||||
}
|
||||
// Trusted-source-linked official installs remain authoritative sync targets
|
||||
// even when their plugin entry is disabled.
|
||||
const officialNpm = resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record });
|
||||
const officialClawHub = resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record });
|
||||
if (officialNpm || officialClawHub) {
|
||||
filtered[pluginId] = record;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -7,28 +7,17 @@ import type { PluginBundleFormat } from "../../plugins/manifest-types.js";
|
||||
import { resolvePackageExtensionEntries, type PackageManifest } from "../../plugins/manifest.js";
|
||||
import { validatePackageExtensionEntriesForInstall } from "../../plugins/package-entry-resolution.js";
|
||||
import { auditOpenClawPeerDependencyLink } from "../../plugins/plugin-peer-link.js";
|
||||
import type { PluginVerificationFailureReason } from "../../plugins/runtime-degraded-state.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
|
||||
type PluginPayloadSmokeFailureReason =
|
||||
| "missing-install-path"
|
||||
| "missing-package-dir"
|
||||
| "missing-package-json"
|
||||
| "unreadable-package-json"
|
||||
| "invalid-package-json"
|
||||
| "missing-bundle-manifest"
|
||||
| "invalid-bundle-manifest"
|
||||
| "missing-main-entry"
|
||||
| "missing-extension-entry"
|
||||
| "missing-openclaw-peer-link";
|
||||
|
||||
export type PluginPayloadSmokeFailure = {
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
reason: PluginPayloadSmokeFailureReason;
|
||||
reason: PluginVerificationFailureReason;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type PluginPayloadSmokeResult = {
|
||||
export type PluginPayloadSmokeResult = {
|
||||
checked: string[];
|
||||
failures: PluginPayloadSmokeFailure[];
|
||||
};
|
||||
|
||||
@@ -29,8 +29,11 @@ vi.mock("./plugin-payload-validation.js", () => ({
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import {
|
||||
convergenceWarningsToOutcomes,
|
||||
filterRecordsToActive,
|
||||
runActivePluginPayloadSmokeCheck,
|
||||
} from "./active-plugin-payload-validation.js";
|
||||
import {
|
||||
convergenceWarningsToOutcomes,
|
||||
runPostCorePluginConvergence,
|
||||
} from "./post-core-plugin-convergence.js";
|
||||
|
||||
@@ -110,6 +113,28 @@ describe("runPostCorePluginConvergence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("checks active payloads without running repair or peer-link convergence", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
deny: ["disabled"],
|
||||
entries: { active: { enabled: true }, disabled: { enabled: true } },
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
const records = {
|
||||
active: { source: "npm" as const, installPath: "/p/active" },
|
||||
disabled: { source: "npm" as const, installPath: "/p/disabled" },
|
||||
};
|
||||
|
||||
await runActivePluginPayloadSmokeCheck({ cfg, records, env: { OPENCLAW_STATE_DIR: "/state" } });
|
||||
|
||||
expect(mocks.runPluginPayloadSmokeCheck).toHaveBeenCalledWith({
|
||||
records: { active: records.active },
|
||||
env: { OPENCLAW_STATE_DIR: "/state" },
|
||||
});
|
||||
expect(mocks.repairMissingConfiguredPluginInstalls).not.toHaveBeenCalled();
|
||||
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the candidate runtime version over a stale inherited host version", async () => {
|
||||
const cfg = { plugins: { entries: {} } } as unknown as OpenClawConfig;
|
||||
await runPostCorePluginConvergence({
|
||||
@@ -189,10 +214,12 @@ describe("runPostCorePluginConvergence", () => {
|
||||
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenNthCalledWith(1, {
|
||||
npmRoot: "/tmp/openclaw-state/npm",
|
||||
logger: {},
|
||||
onPackageReadError: expect.any(Function),
|
||||
});
|
||||
expect(mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot).toHaveBeenNthCalledWith(2, {
|
||||
npmRoot: "/tmp/openclaw-state/npm/projects/codex",
|
||||
logger: {},
|
||||
onPackageReadError: expect.any(Function),
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
"Repaired OpenClaw host peer link(s) for 1 managed npm plugin package(s).",
|
||||
@@ -549,6 +576,105 @@ describe("runPostCorePluginConvergence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not duplicate a package-scoped repair error owned by a smoke failure", async () => {
|
||||
const installPath = "/tmp/openclaw-state/npm/projects/brave/node_modules/brave";
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
records: { brave: { source: "npm", installPath } },
|
||||
});
|
||||
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockImplementation(
|
||||
async (params: { onPackageReadError?: (error: unknown, packageDir: string) => void }) => {
|
||||
params.onPackageReadError?.(
|
||||
new Error(`EACCES: permission denied, open '${installPath}/package.json'`),
|
||||
installPath,
|
||||
);
|
||||
return { checked: 0, attempted: 0, repaired: 0, skipped: 1 };
|
||||
},
|
||||
);
|
||||
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({
|
||||
checked: ["brave"],
|
||||
failures: [
|
||||
{
|
||||
pluginId: "brave",
|
||||
installPath,
|
||||
reason: "unreadable-package-json",
|
||||
detail: `Could not read package.json at ${installPath}/package.json: EACCES: permission denied`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: {
|
||||
plugins: { entries: { brave: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" },
|
||||
});
|
||||
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toMatchObject({
|
||||
pluginId: "brave",
|
||||
reason: expect.stringContaining("unreadable-package-json"),
|
||||
});
|
||||
expect(result.errored).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote an inactive package read error into an ownerless blocker", async () => {
|
||||
const installPath = "/tmp/openclaw-state/npm/projects/brave/node_modules/brave";
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
records: { brave: { source: "npm", installPath } },
|
||||
});
|
||||
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockImplementation(
|
||||
async (params: { onPackageReadError?: (error: unknown, packageDir: string) => void }) => {
|
||||
params.onPackageReadError?.(
|
||||
new Error(`EACCES: permission denied, open '${installPath}/package.json'`),
|
||||
installPath,
|
||||
);
|
||||
return { checked: 0, attempted: 0, repaired: 0, skipped: 1 };
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: {
|
||||
plugins: { entries: { brave: { enabled: false } } },
|
||||
} as unknown as OpenClawConfig,
|
||||
env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" },
|
||||
});
|
||||
|
||||
expect(mocks.runPluginPayloadSmokeCheck).toHaveBeenCalledWith({
|
||||
records: {},
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.errored).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an unowned package read error visible for startup to block", async () => {
|
||||
const packageDir = "/tmp/openclaw-state/npm/node_modules/untracked";
|
||||
mocks.relinkOpenClawPeerDependenciesInManagedNpmRoot.mockImplementation(
|
||||
async (params: { onPackageReadError?: (error: unknown, packageDir: string) => void }) => {
|
||||
params.onPackageReadError?.(new Error("EACCES: permission denied"), packageDir);
|
||||
return { checked: 0, attempted: 0, repaired: 0, skipped: 1 };
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg: { plugins: { entries: {} } } as unknown as OpenClawConfig,
|
||||
env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" },
|
||||
});
|
||||
|
||||
expect(result.warnings).toStrictEqual([
|
||||
{
|
||||
reason: "Failed to repair managed npm OpenClaw host peer links: EACCES: permission denied",
|
||||
message: "Failed to repair managed npm OpenClaw host peer links: EACCES: permission denied",
|
||||
guidance: ["Run `openclaw update repair` to retry plugin repair."],
|
||||
},
|
||||
]);
|
||||
expect(result.errored).toBe(false);
|
||||
});
|
||||
|
||||
it("hands repair's post-mutation records straight to the smoke check (no second disk read)", async () => {
|
||||
const records = { brave: { source: "npm" as const, installPath: "/p/brave" } };
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
|
||||
@@ -5,20 +5,17 @@ import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/u
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
||||
import { resolveDefaultPluginNpmDir } from "../../plugins/install-paths.js";
|
||||
import { listManagedPluginNpmRoots } from "../../plugins/npm-project-roots.js";
|
||||
import {
|
||||
resolveTrustedSourceLinkedOfficialClawHubSpec,
|
||||
resolveTrustedSourceLinkedOfficialNpmSpec,
|
||||
} from "../../plugins/official-external-install-records.js";
|
||||
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "../../plugins/plugin-peer-link.js";
|
||||
import { pruneStaleLocalBundledPluginInstallRecords } from "../../plugins/stale-local-bundled-plugin-install-records.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import {
|
||||
runPluginPayloadSmokeCheck,
|
||||
type PluginPayloadSmokeFailure,
|
||||
} from "./plugin-payload-validation.js";
|
||||
filterRecordsToActive,
|
||||
runActivePluginPayloadSmokeCheck,
|
||||
} from "./active-plugin-payload-validation.js";
|
||||
import type { PluginPayloadSmokeFailure } from "./plugin-payload-validation.js";
|
||||
|
||||
type PostCoreConvergenceWarning = {
|
||||
pluginId?: string;
|
||||
@@ -63,9 +60,12 @@ function smokeFailureGuidance(failure: PluginPayloadSmokeFailure): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
async function repairManagedNpmOpenClawPeerLinks(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ changes: string[]; warnings: PostCoreConvergenceWarning[] }> {
|
||||
async function repairManagedNpmOpenClawPeerLinks(params: { env: NodeJS.ProcessEnv }): Promise<{
|
||||
changes: string[];
|
||||
warnings: PostCoreConvergenceWarning[];
|
||||
packageReadFailures: Array<{ error: unknown; packageDir: string }>;
|
||||
}> {
|
||||
const packageReadFailures: Array<{ error: unknown; packageDir: string }> = [];
|
||||
try {
|
||||
const npmRoots = await listManagedPluginNpmRoots(resolveDefaultPluginNpmDir(params.env));
|
||||
const results = await Promise.all(
|
||||
@@ -73,6 +73,9 @@ async function repairManagedNpmOpenClawPeerLinks(params: {
|
||||
relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
logger: {},
|
||||
onPackageReadError: (error, packageDir) => {
|
||||
packageReadFailures.push({ error, packageDir });
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -83,6 +86,7 @@ async function repairManagedNpmOpenClawPeerLinks(params: {
|
||||
? [`Repaired OpenClaw host peer link(s) for ${repaired} managed npm plugin package(s).`]
|
||||
: [],
|
||||
warnings: [],
|
||||
packageReadFailures,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = `Failed to repair managed npm OpenClaw host peer links: ${err instanceof Error ? err.message : String(err)}`;
|
||||
@@ -95,17 +99,29 @@ async function repairManagedNpmOpenClawPeerLinks(params: {
|
||||
guidance: [REPAIR_GUIDANCE],
|
||||
},
|
||||
],
|
||||
packageReadFailures,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatPeerLinkPackageReadWarning(failure: { error: unknown }): PostCoreConvergenceWarning {
|
||||
const message = `Failed to repair managed npm OpenClaw host peer links: ${failure.error instanceof Error ? failure.error.message : String(failure.error)}`;
|
||||
return {
|
||||
reason: message,
|
||||
message,
|
||||
guidance: [REPAIR_GUIDANCE],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandatory post-core convergence pass. Runs AFTER the core package files
|
||||
* are swapped and the in-update doctor pass has already returned, but BEFORE
|
||||
* the gateway is restarted. Missing-plugin repair failures stay nonblocking:
|
||||
* an external package fetch may be transient, and failing the core update
|
||||
* would strand the user. Payload smoke failures still block the restart so we
|
||||
* never restart with an installed active plugin whose payload is unloadable.
|
||||
* would strand the user. Explicit `openclaw update` callers keep reporting
|
||||
* payload smoke failures as errors. Gateway startup consumes the same typed
|
||||
* failures by quarantining each known plugin owner before any module import,
|
||||
* then boots with that plugin marked configured-unavailable.
|
||||
*/
|
||||
export async function runPostCorePluginConvergence(params: {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -163,8 +179,42 @@ export async function runPostCorePluginConvergence(params: {
|
||||
// this filter, a stale install record for a disabled or no-longer-
|
||||
// configured plugin whose payload was deleted on disk would block the
|
||||
// entire update — even though the gateway will never load that plugin.
|
||||
const smoke = await runActivePluginPayloadSmokeCheck({
|
||||
cfg: params.cfg,
|
||||
records,
|
||||
env,
|
||||
});
|
||||
const smokeRecords = filterRecordsToActive({ cfg: params.cfg, records });
|
||||
const smoke = await runPluginPayloadSmokeCheck({ records: smokeRecords, env });
|
||||
const resolveInstallRecordPaths = (
|
||||
installRecords: Record<string, PluginInstallRecord>,
|
||||
): Set<string> =>
|
||||
new Set(
|
||||
Object.values(installRecords).flatMap((record) => {
|
||||
const installPath = record.installPath?.trim();
|
||||
return installPath ? [path.resolve(resolveUserPath(installPath, env))] : [];
|
||||
}),
|
||||
);
|
||||
const knownInstallPaths = resolveInstallRecordPaths(records);
|
||||
const activeInstallPaths = resolveInstallRecordPaths(smokeRecords);
|
||||
const smokeFailureInstallPaths = new Set(
|
||||
smoke.failures.flatMap((failure) =>
|
||||
failure.installPath ? [path.resolve(failure.installPath)] : [],
|
||||
),
|
||||
);
|
||||
for (const failure of peerLinkRepair.packageReadFailures.toSorted((left, right) =>
|
||||
left.packageDir.localeCompare(right.packageDir),
|
||||
)) {
|
||||
// A typed smoke failure owns this exact package and startup quarantines it.
|
||||
// Re-emitting the repair error without that owner would turn it back into
|
||||
// an unknown warning and incorrectly block gateway readiness.
|
||||
const packageDir = path.resolve(failure.packageDir);
|
||||
const hasTypedFailure = smokeFailureInstallPaths.has(packageDir);
|
||||
const belongsToInactivePlugin =
|
||||
knownInstallPaths.has(packageDir) && !activeInstallPaths.has(packageDir);
|
||||
if (!hasTypedFailure && !belongsToInactivePlugin) {
|
||||
warnings.push(formatPeerLinkPackageReadWarning(failure));
|
||||
}
|
||||
}
|
||||
for (const failure of smoke.failures) {
|
||||
warnings.push({
|
||||
pluginId: failure.pluginId,
|
||||
@@ -202,38 +252,6 @@ export async function runPostCorePluginConvergence(params: {
|
||||
* that are enabled implicitly via auth profiles or model refs. Effective
|
||||
* enable state is the right precision boundary.
|
||||
*/
|
||||
export function filterRecordsToActive(params: {
|
||||
cfg: OpenClawConfig;
|
||||
records: Record<string, PluginInstallRecord>;
|
||||
}): Record<string, PluginInstallRecord> {
|
||||
const normalizedPluginConfig = normalizePluginsConfig(params.cfg.plugins);
|
||||
const filtered: Record<string, PluginInstallRecord> = {};
|
||||
for (const [pluginId, record] of Object.entries(params.records)) {
|
||||
if (!record || typeof record !== "object") {
|
||||
continue;
|
||||
}
|
||||
const enableState = resolveEffectiveEnableState({
|
||||
id: pluginId,
|
||||
origin: "global",
|
||||
config: normalizedPluginConfig,
|
||||
rootConfig: params.cfg,
|
||||
});
|
||||
if (enableState.enabled) {
|
||||
filtered[pluginId] = record;
|
||||
continue;
|
||||
}
|
||||
// Even when disabled, retain trusted-source-linked official installs
|
||||
// because the existing post-update sync path treats them as
|
||||
// authoritative regardless of the entry's enable flag.
|
||||
const officialNpm = resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record });
|
||||
const officialClawHub = resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record });
|
||||
if (officialNpm || officialClawHub) {
|
||||
filtered[pluginId] = record;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper used by `updatePluginsAfterCoreUpdate` to fold a convergence
|
||||
* result into the existing `PluginUpdateOutcome[]` / warning shape that the
|
||||
@@ -244,8 +262,8 @@ export function filterRecordsToActive(params: {
|
||||
* warnings that name a `pluginId` produce per-plugin error outcomes; the
|
||||
* rest are surfaced via `warnings`.
|
||||
* - `errored` boolean that callers translate into `status: "error"`.
|
||||
* Repair warnings are nonblocking; smoke failures remain blocking
|
||||
* because they prove an active installed payload is unloadable.
|
||||
* Repair warnings are nonblocking; smoke failures remain errors on the
|
||||
* explicit update path even though Gateway startup can quarantine them.
|
||||
*/
|
||||
export function convergenceWarningsToOutcomes(convergence: PostCoreConvergenceResult): {
|
||||
warnings: PostCoreConvergenceWarning[];
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Doctor config preflight tests cover state migration preflight behavior before config repair.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listActiveDegradedPlugins,
|
||||
setActiveDegradedPlugins,
|
||||
} from "../plugins/runtime-degraded-state.js";
|
||||
import { ExitError } from "../runtime.js";
|
||||
|
||||
type StateMigrationResult = {
|
||||
@@ -11,16 +15,25 @@ type StateMigrationResult = {
|
||||
};
|
||||
|
||||
type StartupConvergenceWarning = {
|
||||
pluginId?: string;
|
||||
reason: string;
|
||||
message: string;
|
||||
guidance: string[];
|
||||
};
|
||||
|
||||
type StartupSmokeFailure = {
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
reason: "missing-install-path" | "missing-main-entry" | "unreadable-package-json";
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type StartupConvergenceResult = {
|
||||
changes: string[];
|
||||
notices?: StartupConvergenceWarning[];
|
||||
warnings: StartupConvergenceWarning[];
|
||||
errored: boolean;
|
||||
smokeFailures: unknown[];
|
||||
smokeFailures: StartupSmokeFailure[];
|
||||
installRecords: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -100,6 +113,9 @@ const runPostCorePluginConvergence = vi.hoisted(() =>
|
||||
}),
|
||||
),
|
||||
);
|
||||
const runActivePluginPayloadSmokeCheck = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ checked: [] as string[], failures: [] as StartupSmokeFailure[] })),
|
||||
);
|
||||
const planStartupPluginConvergence = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ required: true, installRecords: {} })),
|
||||
);
|
||||
@@ -153,6 +169,10 @@ vi.mock("../infra/startup-migration-checkpoint.js", () => ({
|
||||
recordSuccessfulStartupMigrations,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/update-cli/active-plugin-payload-validation.js", () => ({
|
||||
runActivePluginPayloadSmokeCheck,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/update-cli/post-core-plugin-convergence.js", () => ({
|
||||
runPostCorePluginConvergence,
|
||||
}));
|
||||
@@ -178,6 +198,7 @@ const { runDoctorConfigPreflight } = await import("./doctor-config-preflight.js"
|
||||
describe("runDoctorConfigPreflight state migration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActiveDegradedPlugins([]);
|
||||
needsStartupMigrationCheckpoint.mockReturnValue(false);
|
||||
runPostCorePluginConvergence.mockResolvedValue(makeStartupConvergenceResult());
|
||||
planStartupPluginConvergence.mockResolvedValue({ required: true, installRecords: {} });
|
||||
@@ -451,7 +472,42 @@ describe("runDoctorConfigPreflight state migration", () => {
|
||||
expect(startupMigrationLeaseRelease).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not acquire the startup migration lease when the checkpoint is current", async () => {
|
||||
it("refreshes plugin quarantine without repair when the checkpoint is current", async () => {
|
||||
planStartupPluginConvergence.mockResolvedValueOnce({
|
||||
required: true,
|
||||
installRecords: { discord: { source: "npm", installPath: "/plugins/discord" } },
|
||||
});
|
||||
runActivePluginPayloadSmokeCheck.mockResolvedValueOnce({
|
||||
checked: ["discord"],
|
||||
failures: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
installPath: "/plugins/discord",
|
||||
reason: "missing-main-entry",
|
||||
detail: "index.js",
|
||||
},
|
||||
],
|
||||
});
|
||||
readConfigFileSnapshot.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
sourceConfig: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
parsed: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
legacyIssues: [],
|
||||
warnings: [],
|
||||
issues: [],
|
||||
});
|
||||
|
||||
await runDoctorConfigPreflight({
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
@@ -465,9 +521,125 @@ describe("runDoctorConfigPreflight state migration", () => {
|
||||
expect(autoMigrateLegacyState).not.toHaveBeenCalled();
|
||||
expect(autoMigrateLegacyTaskStateSidecars).not.toHaveBeenCalled();
|
||||
expect(runPostCorePluginConvergence).not.toHaveBeenCalled();
|
||||
expect(runActivePluginPayloadSmokeCheck).toHaveBeenCalledWith({
|
||||
cfg: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
records: { discord: { source: "npm", installPath: "/plugins/discord" } },
|
||||
env: process.env,
|
||||
});
|
||||
expect(listActiveDegradedPlugins()).toMatchObject([
|
||||
{
|
||||
pluginId: "discord",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: { reason: "missing-main-entry" },
|
||||
},
|
||||
]);
|
||||
expect(readConfigFileSnapshot).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps ownerless payload failures blocking when the checkpoint is current", async () => {
|
||||
planStartupPluginConvergence.mockResolvedValueOnce({
|
||||
required: true,
|
||||
installRecords: { discord: { source: "npm" } },
|
||||
});
|
||||
runActivePluginPayloadSmokeCheck.mockResolvedValueOnce({
|
||||
checked: ["discord"],
|
||||
failures: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
reason: "missing-install-path",
|
||||
detail: "Install path is missing from the plugin install record.",
|
||||
},
|
||||
],
|
||||
});
|
||||
readConfigFileSnapshot.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
sourceConfig: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
parsed: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
legacyIssues: [],
|
||||
warnings: [],
|
||||
issues: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runDoctorConfigPreflight({
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
requireStartupMigrationCheckpoint: true,
|
||||
}),
|
||||
).rejects.toThrow("Install path is missing from the plugin install record.");
|
||||
|
||||
expect(runPostCorePluginConvergence).not.toHaveBeenCalled();
|
||||
expect(listActiveDegradedPlugins()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps ownerless install-record failures blocking", async () => {
|
||||
needsStartupMigrationCheckpoint.mockReturnValue(true);
|
||||
readConfigFileSnapshot.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
sourceConfig: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
parsed: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
legacyIssues: [],
|
||||
warnings: [],
|
||||
issues: [],
|
||||
});
|
||||
runPostCorePluginConvergence.mockResolvedValueOnce(
|
||||
makeStartupConvergenceResult({
|
||||
errored: true,
|
||||
warnings: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
reason: "missing-install-path: install path missing",
|
||||
message: 'Plugin "discord" has no install path.',
|
||||
guidance: ["Run `openclaw update repair` to retry plugin repair."],
|
||||
},
|
||||
],
|
||||
smokeFailures: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
reason: "missing-install-path",
|
||||
detail: "install path missing",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runDoctorConfigPreflight({
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
requireStartupMigrationCheckpoint: true,
|
||||
}),
|
||||
).rejects.toThrow('Plugin "discord" has no install path.');
|
||||
|
||||
expect(listActiveDegradedPlugins()).toEqual([]);
|
||||
expect(recordSuccessfulStartupMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checkpoints startup migrations without loading plugin convergence when the plan is empty", async () => {
|
||||
needsStartupMigrationCheckpoint.mockReturnValue(true);
|
||||
planStartupPluginConvergence.mockResolvedValueOnce({ required: false, installRecords: {} });
|
||||
@@ -577,6 +749,7 @@ describe("runDoctorConfigPreflight state migration", () => {
|
||||
makeStartupConvergenceResult({
|
||||
warnings: [
|
||||
{
|
||||
reason: "Configured plugin discord is not installed.",
|
||||
message: "Configured plugin discord is not installed.",
|
||||
guidance: ["Run `openclaw update repair` to retry plugin repair."],
|
||||
},
|
||||
@@ -600,13 +773,34 @@ describe("runDoctorConfigPreflight state migration", () => {
|
||||
expect(startupMigrationLeaseRelease).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks gateway readiness when plugin convergence reports an error", async () => {
|
||||
it("quarantines a plugin payload verification failure and checkpoints readiness", async () => {
|
||||
needsStartupMigrationCheckpoint.mockReturnValue(true);
|
||||
readConfigFileSnapshot.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
valid: true,
|
||||
config: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
sourceConfig: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
parsed: {
|
||||
gateway: { mode: "local", port: 19091 },
|
||||
plugins: { entries: { discord: { enabled: true } } },
|
||||
},
|
||||
legacyIssues: [],
|
||||
warnings: [],
|
||||
issues: [],
|
||||
});
|
||||
runPostCorePluginConvergence.mockResolvedValueOnce(
|
||||
makeStartupConvergenceResult({
|
||||
errored: true,
|
||||
warnings: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
reason: "missing-main-entry: index.js",
|
||||
message: 'Plugin "discord" failed post-core payload smoke check (missing): index.js',
|
||||
guidance: [
|
||||
"Run `openclaw update repair` to retry plugin repair.",
|
||||
@@ -614,20 +808,43 @@ describe("runDoctorConfigPreflight state migration", () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
smokeFailures: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
installPath: "/plugins/discord",
|
||||
reason: "missing-main-entry",
|
||||
detail: "index.js",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runDoctorConfigPreflight({
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
requireStartupMigrationCheckpoint: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'OpenClaw plugin verification failed; refusing to report the gateway ready.\n- Plugin "discord" failed post-core payload smoke check',
|
||||
);
|
||||
await runDoctorConfigPreflight({
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
requireStartupMigrationCheckpoint: true,
|
||||
});
|
||||
|
||||
expect(recordSuccessfulStartupMigrations).not.toHaveBeenCalled();
|
||||
expect(listActiveDegradedPlugins()).toEqual([
|
||||
{
|
||||
pluginId: "discord",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "missing-main-entry",
|
||||
detail: "index.js",
|
||||
installPath: "/plugins/discord",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'- Plugin "discord" failed post-core payload smoke check (missing): index.js',
|
||||
),
|
||||
"Doctor warnings",
|
||||
);
|
||||
expect(note.mock.calls.filter(([, title]) => title === "Doctor warnings")).toHaveLength(1);
|
||||
expect(recordSuccessfulStartupMigrations).toHaveBeenCalledOnce();
|
||||
expect(startupMigrationLeaseRelease).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import type { PluginPayloadSmokeFailure } from "../cli/update-cli/plugin-payload-validation.js";
|
||||
import { cloneEnvWithPlatformSemantics } from "../config/env-vars.js";
|
||||
import {
|
||||
parseConfigJson5,
|
||||
@@ -15,6 +16,13 @@ import type { ConfigFileSnapshot, LegacyConfigIssue } from "../config/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import type { StartupMigrationLease } from "../infra/startup-migration-checkpoint.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
|
||||
import {
|
||||
buildDegradedPluginsFromVerificationFailures,
|
||||
formatPluginVerificationDiagnostic,
|
||||
setActiveDegradedPlugins,
|
||||
type DegradedPlugin,
|
||||
} from "../plugins/runtime-degraded-state.js";
|
||||
import { ExitError } from "../runtime.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import { resolveHomeDir } from "../utils.js";
|
||||
@@ -147,22 +155,68 @@ type StartupPluginVerificationDiagnostic = {
|
||||
messages: string[];
|
||||
};
|
||||
|
||||
async function runStartupUpgradeConvergence(params: {
|
||||
type StartupPluginConvergenceResult = {
|
||||
blockingDiagnostic: StartupPluginVerificationDiagnostic | null;
|
||||
quarantinedPlugins: DegradedPlugin[];
|
||||
};
|
||||
|
||||
async function planStartupPluginVerification(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<StartupPluginVerificationDiagnostic | null> {
|
||||
}) {
|
||||
const { planStartupPluginConvergence } = await measureStartupPreflightStep(
|
||||
"plugin-plan-import",
|
||||
() => import("./doctor/shared/startup-plugin-convergence-plan.js"),
|
||||
);
|
||||
const plan = await measureStartupPreflightStep("plugin-plan", () =>
|
||||
return await measureStartupPreflightStep("plugin-plan", () =>
|
||||
planStartupPluginConvergence({
|
||||
config: params.cfg,
|
||||
env: params.env,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildStartupPluginQuarantine(params: {
|
||||
cfg: OpenClawConfig;
|
||||
failures: readonly PluginPayloadSmokeFailure[];
|
||||
}): DegradedPlugin[] {
|
||||
return buildDegradedPluginsFromVerificationFailures(
|
||||
params.failures.filter(
|
||||
(failure) =>
|
||||
Boolean(failure.installPath) &&
|
||||
isStartupPluginVerificationFailureActive({ cfg: params.cfg, failure }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isStartupPluginVerificationFailureActive(params: {
|
||||
cfg: OpenClawConfig;
|
||||
failure: PluginPayloadSmokeFailure;
|
||||
}): boolean {
|
||||
return resolveEffectiveEnableState({
|
||||
id: params.failure.pluginId,
|
||||
origin: "global",
|
||||
config: normalizePluginsConfig(params.cfg.plugins),
|
||||
rootConfig: params.cfg,
|
||||
}).enabled;
|
||||
}
|
||||
|
||||
function formatStartupPluginSmokeFailure(failure: PluginPayloadSmokeFailure): string {
|
||||
return `Plugin "${failure.pluginId}": ${formatPluginVerificationDiagnostic({
|
||||
kind: "plugin-verification",
|
||||
reason: failure.reason,
|
||||
detail: failure.detail,
|
||||
...(failure.installPath ? { installPath: failure.installPath } : {}),
|
||||
})}. Run \`openclaw update repair\` to retry plugin repair.`;
|
||||
}
|
||||
|
||||
async function runStartupUpgradeConvergence(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<StartupPluginConvergenceResult> {
|
||||
const plan = await planStartupPluginVerification(params);
|
||||
if (!plan.required) {
|
||||
return null;
|
||||
return { blockingDiagnostic: null, quarantinedPlugins: [] };
|
||||
}
|
||||
const { runPostCorePluginConvergence } = await measureStartupPreflightStep(
|
||||
"plugin-convergence-import",
|
||||
@@ -191,7 +245,91 @@ async function runStartupUpgradeConvergence(params: {
|
||||
if (warnings.length > 0) {
|
||||
note(warnings.map((warning) => `- ${warning}`).join("\n"), "Doctor warnings");
|
||||
}
|
||||
return warnings.length > 0 ? { kind: "plugin-verification", messages: warnings } : null;
|
||||
const quarantinedPlugins = buildStartupPluginQuarantine({
|
||||
cfg: params.cfg,
|
||||
failures: convergence.smokeFailures,
|
||||
});
|
||||
const nonBlockingWarningKeys = new Set(
|
||||
convergence.smokeFailures
|
||||
.filter(
|
||||
(failure) =>
|
||||
Boolean(failure.installPath) ||
|
||||
!isStartupPluginVerificationFailureActive({ cfg: params.cfg, failure }),
|
||||
)
|
||||
.map((failure) => JSON.stringify([failure.pluginId, `${failure.reason}: ${failure.detail}`])),
|
||||
);
|
||||
const blockingMessages = convergence.warnings
|
||||
.filter(
|
||||
(warning) =>
|
||||
!warning.pluginId ||
|
||||
!nonBlockingWarningKeys.has(JSON.stringify([warning.pluginId, warning.reason])),
|
||||
)
|
||||
.map((warning) => `${warning.message} ${warning.guidance.join(" ")}`.trim());
|
||||
return {
|
||||
blockingDiagnostic:
|
||||
blockingMessages.length > 0
|
||||
? { kind: "plugin-verification", messages: blockingMessages }
|
||||
: null,
|
||||
quarantinedPlugins,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshStartupPluginQuarantine(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<StartupPluginConvergenceResult> {
|
||||
const plan = await planStartupPluginVerification(params);
|
||||
if (!plan.required) {
|
||||
return { blockingDiagnostic: null, quarantinedPlugins: [] };
|
||||
}
|
||||
const { runActivePluginPayloadSmokeCheck } = await measureStartupPreflightStep(
|
||||
"plugin-payload-verification-import",
|
||||
() => import("../cli/update-cli/active-plugin-payload-validation.js"),
|
||||
);
|
||||
const smoke = await measureStartupPreflightStep("plugin-payload-verification", () =>
|
||||
runActivePluginPayloadSmokeCheck({
|
||||
cfg: params.cfg,
|
||||
records: plan.installRecords,
|
||||
env: params.env,
|
||||
}),
|
||||
);
|
||||
const quarantinedPlugins = buildStartupPluginQuarantine({
|
||||
cfg: params.cfg,
|
||||
failures: smoke.failures,
|
||||
});
|
||||
const blockingFailures = smoke.failures.filter(
|
||||
(failure) =>
|
||||
!failure.installPath &&
|
||||
isStartupPluginVerificationFailureActive({ cfg: params.cfg, failure }),
|
||||
);
|
||||
if (quarantinedPlugins.length > 0) {
|
||||
note(
|
||||
quarantinedPlugins
|
||||
.map(
|
||||
(plugin) =>
|
||||
`- ${formatStartupPluginSmokeFailure({
|
||||
pluginId: plugin.pluginId,
|
||||
reason: plugin.diagnostic.reason,
|
||||
detail: plugin.diagnostic.detail,
|
||||
...(plugin.diagnostic.installPath
|
||||
? { installPath: plugin.diagnostic.installPath }
|
||||
: {}),
|
||||
})}`,
|
||||
)
|
||||
.join("\n"),
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
return {
|
||||
blockingDiagnostic:
|
||||
blockingFailures.length > 0
|
||||
? {
|
||||
kind: "plugin-verification",
|
||||
messages: blockingFailures.map(formatStartupPluginSmokeFailure),
|
||||
}
|
||||
: null,
|
||||
quarantinedPlugins,
|
||||
};
|
||||
}
|
||||
|
||||
function formatStartupMigrationFailure(params: { warnings: string[]; blockers: string[] }): string {
|
||||
@@ -476,41 +614,57 @@ export async function runDoctorConfigPreflight(
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRecordStartupCheckpoint) {
|
||||
if (startupMigrationHeartbeatError) {
|
||||
throw startupMigrationHeartbeatError instanceof Error
|
||||
? startupMigrationHeartbeatError
|
||||
: new Error("OpenClaw startup migration lease heartbeat failed.");
|
||||
if (startupCheckpoint) {
|
||||
if (shouldRecordStartupCheckpoint) {
|
||||
if (startupMigrationHeartbeatError) {
|
||||
throw startupMigrationHeartbeatError instanceof Error
|
||||
? startupMigrationHeartbeatError
|
||||
: new Error("OpenClaw startup migration lease heartbeat failed.");
|
||||
}
|
||||
if (startupMigrationWarnings.length > 0) {
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupMigrationFailure({
|
||||
warnings: startupMigrationWarnings,
|
||||
blockers: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupMigrationFailure({
|
||||
warnings: [],
|
||||
blockers: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (startupMigrationWarnings.length > 0) {
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupMigrationFailure({
|
||||
warnings: startupMigrationWarnings,
|
||||
blockers: [],
|
||||
}),
|
||||
);
|
||||
// This state is established before the first Gateway plugin load and remains
|
||||
// fixed for the boot. Refresh it on every process start because migration
|
||||
// checkpoints do not persist plugin availability or quarantine state.
|
||||
setActiveDegradedPlugins([]);
|
||||
if (snapshot.valid) {
|
||||
const pluginConvergence = shouldRecordStartupCheckpoint
|
||||
? await runStartupUpgradeConvergence({
|
||||
cfg: baseConfig,
|
||||
env: process.env,
|
||||
})
|
||||
: await refreshStartupPluginQuarantine({
|
||||
cfg: baseConfig,
|
||||
env: process.env,
|
||||
});
|
||||
setActiveDegradedPlugins(pluginConvergence.quarantinedPlugins);
|
||||
if (pluginConvergence.blockingDiagnostic) {
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupPluginVerificationFailure(pluginConvergence.blockingDiagnostic),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupMigrationFailure({
|
||||
warnings: [],
|
||||
blockers: ['OpenClaw config is invalid; run "openclaw doctor --fix" before startup.'],
|
||||
}),
|
||||
);
|
||||
if (shouldRecordStartupCheckpoint) {
|
||||
startupCheckpoint.recordSuccessfulStartupMigrations({
|
||||
env: startupMigrationEnv,
|
||||
lease: startupMigrationLease,
|
||||
});
|
||||
}
|
||||
const pluginVerificationDiagnostic = await runStartupUpgradeConvergence({
|
||||
cfg: baseConfig,
|
||||
env: process.env,
|
||||
});
|
||||
if (pluginVerificationDiagnostic) {
|
||||
throwStartupMigrationRefusal(
|
||||
formatStartupPluginVerificationFailure(pluginVerificationDiagnostic),
|
||||
);
|
||||
}
|
||||
startupCheckpoint?.recordSuccessfulStartupMigrations({
|
||||
env: startupMigrationEnv,
|
||||
lease: startupMigrationLease,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -145,6 +145,44 @@ describe("checkGatewayHealth", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lists every plugin configured unavailable by Gateway startup", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
degradedPlugins: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "unreadable-package-json",
|
||||
detail: "permission denied",
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "matrix",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "missing-main-entry",
|
||||
detail: "dist/index.js is missing",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
await checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 });
|
||||
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
[
|
||||
"- discord (unreadable-package-json): permission denied",
|
||||
"- matrix (missing-main-entry): dist/index.js is missing",
|
||||
].join("\n"),
|
||||
"Plugins configured unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not run follow-up channel probes when liveness fails", async () => {
|
||||
callGateway.mockRejectedValueOnce(new Error("gateway timeout after 3000ms"));
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
@@ -97,6 +97,17 @@ export async function checkGatewayHealth(params: {
|
||||
"Secret owners unavailable",
|
||||
);
|
||||
}
|
||||
if (status.degradedPlugins && status.degradedPlugins.length > 0) {
|
||||
note(
|
||||
status.degradedPlugins
|
||||
.map(
|
||||
(plugin) =>
|
||||
`- ${plugin.pluginId} (${plugin.diagnostic.reason}): ${plugin.diagnostic.detail}`,
|
||||
)
|
||||
.join("\n"),
|
||||
"Plugins configured unavailable",
|
||||
);
|
||||
}
|
||||
try {
|
||||
const statusLocal = await callGateway({
|
||||
method: "channels.status",
|
||||
|
||||
@@ -922,4 +922,37 @@ describe("maybeRepairPluginRegistryState", () => {
|
||||
expect(notes).toContain("openclaw doctor --fix");
|
||||
expect(fs.existsSync(linkPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports an unreadable managed npm package without aborting doctor", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const managed = createManagedNpmPlugin({
|
||||
stateDir,
|
||||
id: "broken",
|
||||
packageName: "broken-plugin",
|
||||
version: "2026.5.3",
|
||||
});
|
||||
fs.writeFileSync(path.join(managed.packageDir, "package.json"), "{", "utf8");
|
||||
await writePersistedInstalledPluginIndex(
|
||||
createCurrentIndexWithNpmRecord({
|
||||
pluginId: "broken",
|
||||
packageName: "broken-plugin",
|
||||
packageDir: managed.packageDir,
|
||||
version: "2026.5.3",
|
||||
}),
|
||||
{ stateDir },
|
||||
);
|
||||
|
||||
await expect(
|
||||
maybeRepairPluginRegistryState({
|
||||
stateDir,
|
||||
env: hermeticEnv(),
|
||||
config: {},
|
||||
prompter: { shouldRepair: false },
|
||||
}),
|
||||
).resolves.toEqual({});
|
||||
|
||||
const notes = vi.mocked(note).mock.calls.join("\n");
|
||||
expect(notes).toContain("Managed npm plugin packages could not be inspected");
|
||||
expect(notes).toContain("broken-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,8 +80,22 @@ type PluginRegistryHealthIssue =
|
||||
packageName: string;
|
||||
packageDir: string;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
kind: "managed-npm-package-unreadable";
|
||||
packageDir: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type ManagedNpmPackageReadFailure = {
|
||||
packageDir: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function formatPackageReadFailure(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function readJsonObject(filePath: string): Record<string, unknown> | null {
|
||||
const parsed = tryReadJsonSync(filePath);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
@@ -357,8 +371,19 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks(
|
||||
): Promise<boolean> {
|
||||
const npmRoots = listManagedPluginNpmRoots(params);
|
||||
if (!params.prompter.shouldRepair) {
|
||||
const packageReadFailures: ManagedNpmPackageReadFailure[] = [];
|
||||
const audits = await Promise.all(
|
||||
npmRoots.map((npmRoot) => auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot })),
|
||||
npmRoots.map((npmRoot) =>
|
||||
auditOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
onPackageReadError: (error, packageDir) => {
|
||||
packageReadFailures.push({
|
||||
packageDir,
|
||||
reason: formatPackageReadFailure(error),
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const issues = audits.flatMap((audit) => audit.issues);
|
||||
if (issues.length > 0) {
|
||||
@@ -371,6 +396,17 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks(
|
||||
"Plugin registry",
|
||||
);
|
||||
}
|
||||
if (packageReadFailures.length > 0) {
|
||||
note(
|
||||
[
|
||||
"Managed npm plugin packages could not be inspected:",
|
||||
...packageReadFailures.map(
|
||||
(failure) => `- ${shortenHomePath(failure.packageDir)}: ${failure.reason}`,
|
||||
),
|
||||
].join("\n"),
|
||||
"Plugin registry",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -384,6 +420,11 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks(
|
||||
relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
logger,
|
||||
onPackageReadError: (error, packageDir) => {
|
||||
logger.warn(
|
||||
`Could not inspect managed npm package ${shortenHomePath(packageDir)}: ${formatPackageReadFailure(error)}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -421,13 +462,28 @@ async function loadInstallRecordsWithoutPluginIds(
|
||||
|
||||
async function listManagedNpmOpenClawPeerLinkIssues(
|
||||
params: PluginRegistryDoctorRepairParams,
|
||||
): Promise<OpenClawPeerLinkAuditIssue[]> {
|
||||
): Promise<{
|
||||
peerLinkIssues: OpenClawPeerLinkAuditIssue[];
|
||||
packageReadFailures: ManagedNpmPackageReadFailure[];
|
||||
}> {
|
||||
const packageReadFailures: ManagedNpmPackageReadFailure[] = [];
|
||||
const audits = await Promise.all(
|
||||
listManagedPluginNpmRoots(params).map((npmRoot) =>
|
||||
auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot }),
|
||||
auditOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
onPackageReadError: (error, packageDir) => {
|
||||
packageReadFailures.push({
|
||||
packageDir,
|
||||
reason: formatPackageReadFailure(error),
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
return audits.flatMap((audit) => audit.issues);
|
||||
return {
|
||||
peerLinkIssues: audits.flatMap((audit) => audit.issues),
|
||||
packageReadFailures,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectPluginRegistryHealthIssues(
|
||||
@@ -458,7 +514,8 @@ export async function detectPluginRegistryHealthIssues(
|
||||
stalePath: record.stalePath,
|
||||
});
|
||||
}
|
||||
for (const issue of await listManagedNpmOpenClawPeerLinkIssues(params)) {
|
||||
const managedNpmAudit = await listManagedNpmOpenClawPeerLinkIssues(params);
|
||||
for (const issue of managedNpmAudit.peerLinkIssues) {
|
||||
issues.push({
|
||||
kind: "managed-npm-openclaw-peer-link",
|
||||
packageName: issue.packageName,
|
||||
@@ -466,6 +523,13 @@ export async function detectPluginRegistryHealthIssues(
|
||||
reason: issue.reason,
|
||||
});
|
||||
}
|
||||
for (const failure of managedNpmAudit.packageReadFailures) {
|
||||
issues.push({
|
||||
kind: "managed-npm-package-unreadable",
|
||||
packageDir: failure.packageDir,
|
||||
reason: failure.reason,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -512,6 +576,14 @@ export function pluginRegistryIssueToHealthFinding(
|
||||
target: issue.packageName,
|
||||
fixHint: "Run `openclaw doctor --fix` to relink managed npm plugin packages.",
|
||||
};
|
||||
case "managed-npm-package-unreadable":
|
||||
return {
|
||||
checkId: PLUGIN_REGISTRY_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Managed npm package could not be inspected: ${issue.reason}.`,
|
||||
path: issue.packageDir,
|
||||
fixHint: "Restore access to the package files, then run `openclaw doctor` again.",
|
||||
};
|
||||
}
|
||||
return assertNeverPluginRegistryIssue(issue);
|
||||
}
|
||||
@@ -548,6 +620,13 @@ export function pluginRegistryIssueToRepairEffect(
|
||||
target: issue.packageDir,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "managed-npm-package-unreadable":
|
||||
return {
|
||||
kind: "package",
|
||||
action: "requires-managed-npm-package-readability-repair",
|
||||
target: issue.packageDir,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
}
|
||||
return assertNeverPluginRegistryIssue(issue);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRecord } from "../plugins/status.test-fixtures.js";
|
||||
|
||||
const testConfig = { session: { store: "/tmp/x" } };
|
||||
const tempPaths: string[] = [];
|
||||
|
||||
let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry;
|
||||
let setActiveDegradedPlugins: typeof import("../plugins/runtime-degraded-state.js").setActiveDegradedPlugins;
|
||||
let createTestRegistry: typeof import("../test-utils/channel-plugins.js").createTestRegistry;
|
||||
let getHealthSnapshot: typeof import("./health.js").getHealthSnapshot;
|
||||
|
||||
describe("getHealthSnapshot plugin state", () => {
|
||||
beforeAll(async () => {
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
getRuntimeConfig: () => testConfig,
|
||||
loadConfig: () => testConfig,
|
||||
}));
|
||||
vi.doMock("../config/sessions/paths.js", () => ({
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
}));
|
||||
vi.doMock("../config/sessions/session-accessor.js", () => ({
|
||||
listSessionEntries: () => [],
|
||||
}));
|
||||
vi.doMock("../channels/plugins/read-only.js", () => ({
|
||||
listReadOnlyChannelPluginsForConfig: () => [],
|
||||
}));
|
||||
|
||||
const [pluginsRuntime, degradedState, channelTestUtils, health] = await Promise.all([
|
||||
import("../plugins/runtime.js"),
|
||||
import("../plugins/runtime-degraded-state.js"),
|
||||
import("../test-utils/channel-plugins.js"),
|
||||
import("./health.js"),
|
||||
]);
|
||||
setActivePluginRegistry = pluginsRuntime.setActivePluginRegistry;
|
||||
setActiveDegradedPlugins = degradedState.setActiveDegradedPlugins;
|
||||
createTestRegistry = channelTestUtils.createTestRegistry;
|
||||
getHealthSnapshot = health.getHealthSnapshot;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActiveDegradedPlugins([]);
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
for (const tempPath of tempPaths.splice(0)) {
|
||||
fs.rmSync(tempPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates canonical-root quarantine while retaining unrelated same-id errors", async () => {
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-health-plugin-"));
|
||||
const pluginRootAlias = `${pluginRoot}-alias`;
|
||||
fs.symlinkSync(pluginRoot, pluginRootAlias, "dir");
|
||||
tempPaths.push(pluginRootAlias, pluginRoot);
|
||||
setActivePluginRegistry({
|
||||
...createTestRegistry([]),
|
||||
plugins: [
|
||||
createPluginRecord({
|
||||
id: "discord",
|
||||
origin: "global",
|
||||
rootDir: pluginRoot,
|
||||
status: "error",
|
||||
activated: false,
|
||||
activationReason: "configured-unavailable: unreadable-package-json",
|
||||
failurePhase: "validation",
|
||||
error: "configured plugin payload verification failed",
|
||||
}),
|
||||
createPluginRecord({
|
||||
id: "discord",
|
||||
origin: "config",
|
||||
rootDir: "/workspace/discord",
|
||||
status: "error",
|
||||
activated: false,
|
||||
failurePhase: "load",
|
||||
error: "healthy override has an unrelated import error",
|
||||
}),
|
||||
],
|
||||
});
|
||||
setActiveDegradedPlugins([
|
||||
{
|
||||
pluginId: "discord",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "unreadable-package-json",
|
||||
detail: `Could not read ${pluginRootAlias}/package.json: permission denied`,
|
||||
installPath: pluginRootAlias,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const snap = await getHealthSnapshot({ timeoutMs: 10, probe: false });
|
||||
|
||||
expect(snap.plugins?.unavailable).toEqual([
|
||||
{
|
||||
id: "discord",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "unreadable-package-json",
|
||||
detail: "Could not read <plugin-install>/package.json: permission denied",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(snap.plugins?.unavailable)).not.toContain(pluginRoot);
|
||||
expect(snap.plugins?.errors).toEqual([
|
||||
{
|
||||
id: "discord",
|
||||
origin: "config",
|
||||
activated: false,
|
||||
activationSource: "explicit",
|
||||
failurePhase: "load",
|
||||
error: "healthy override has an unrelated import error",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ let listHealthSessionEntriesCalls: Array<{ agentId?: string; storePath?: string
|
||||
let healthPluginsForTest: HealthTestPlugin[] = [];
|
||||
|
||||
let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry;
|
||||
let setActiveDegradedPlugins: typeof import("../plugins/runtime-degraded-state.js").setActiveDegradedPlugins;
|
||||
let createChannelTestPluginBase: typeof import("../test-utils/channel-plugins.js").createChannelTestPluginBase;
|
||||
let createTestRegistry: typeof import("../test-utils/channel-plugins.js").createTestRegistry;
|
||||
let getHealthSnapshot: typeof import("./health.js").getHealthSnapshot;
|
||||
@@ -88,14 +89,16 @@ async function loadFreshHealthModulesForTest() {
|
||||
listReadOnlyChannelPluginsForConfig: () => healthPluginsForTest,
|
||||
}));
|
||||
|
||||
const [pluginsRuntime, channelTestUtils, health] = await Promise.all([
|
||||
const [pluginsRuntime, pluginDegradedState, channelTestUtils, health] = await Promise.all([
|
||||
import("../plugins/runtime.js"),
|
||||
import("../plugins/runtime-degraded-state.js"),
|
||||
import("../test-utils/channel-plugins.js"),
|
||||
import("./health.js"),
|
||||
]);
|
||||
|
||||
return {
|
||||
setActivePluginRegistry: pluginsRuntime.setActivePluginRegistry,
|
||||
setActiveDegradedPlugins: pluginDegradedState.setActiveDegradedPlugins,
|
||||
createChannelTestPluginBase: channelTestUtils.createChannelTestPluginBase,
|
||||
createTestRegistry: channelTestUtils.createTestRegistry,
|
||||
getHealthSnapshot: health.getHealthSnapshot,
|
||||
@@ -461,6 +464,7 @@ describe("getHealthSnapshot", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
setActivePluginRegistry,
|
||||
setActiveDegradedPlugins,
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
getHealthSnapshot,
|
||||
@@ -468,6 +472,7 @@ describe("getHealthSnapshot", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActiveDegradedPlugins([]);
|
||||
buildTelegramHealthSummaryForTest = buildTelegramHealthSummary;
|
||||
probeTelegramAccountForTestOverride = undefined;
|
||||
listHealthSessionEntriesCalls = [];
|
||||
|
||||
+29
-8
@@ -44,6 +44,11 @@ import { isDiagnosticFlagEnabled } from "../infra/diagnostic-flags.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { formatDurationHuman } from "../infra/format-time/format-duration.js";
|
||||
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js";
|
||||
import {
|
||||
degradedPluginMatchesRoot,
|
||||
listActiveDegradedPlugins,
|
||||
toPublicPluginVerificationDiagnostic,
|
||||
} from "../plugins/runtime-degraded-state.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
@@ -354,15 +359,31 @@ const buildSessionSummary = async (storePath: string, agentId?: string) => {
|
||||
|
||||
function buildPluginHealthSummary(): PluginHealthSummary | undefined {
|
||||
const registry = getActivePluginRegistry();
|
||||
if (!registry) {
|
||||
return undefined;
|
||||
}
|
||||
const loaded = registry.plugins
|
||||
const degradedPlugins = listActiveDegradedPlugins();
|
||||
const unavailable = degradedPlugins
|
||||
.map(({ pluginId, state, diagnostic }) => ({
|
||||
id: pluginId,
|
||||
state,
|
||||
diagnostic: toPublicPluginVerificationDiagnostic(diagnostic),
|
||||
}))
|
||||
.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
const loaded = (registry?.plugins ?? [])
|
||||
.filter((plugin) => plugin.status === "loaded")
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
const errors = registry.plugins
|
||||
.filter((plugin) => plugin.status === "error")
|
||||
const errors = (registry?.plugins ?? [])
|
||||
.filter(
|
||||
(plugin) =>
|
||||
plugin.status === "error" &&
|
||||
!degradedPlugins.some(
|
||||
(degraded) =>
|
||||
plugin.id === degraded.pluginId &&
|
||||
plugin.failurePhase === "validation" &&
|
||||
plugin.activationReason === `configured-unavailable: ${degraded.diagnostic.reason}` &&
|
||||
Boolean(plugin.rootDir) &&
|
||||
degradedPluginMatchesRoot(degraded, plugin.rootDir ?? ""),
|
||||
),
|
||||
)
|
||||
.map((plugin) => {
|
||||
const error: PluginHealthErrorSummary = {
|
||||
id: plugin.id,
|
||||
@@ -382,10 +403,10 @@ function buildPluginHealthSummary(): PluginHealthSummary | undefined {
|
||||
return error;
|
||||
})
|
||||
.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
if (loaded.length === 0 && errors.length === 0) {
|
||||
if (loaded.length === 0 && errors.length === 0 && unavailable.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { loaded, errors };
|
||||
return { loaded, errors, unavailable };
|
||||
}
|
||||
|
||||
function readBooleanField(value: unknown, key: string): boolean | undefined {
|
||||
|
||||
@@ -40,6 +40,15 @@ export type PluginHealthErrorSummary = {
|
||||
export type PluginHealthSummary = {
|
||||
loaded: string[];
|
||||
errors: PluginHealthErrorSummary[];
|
||||
unavailable?: Array<{
|
||||
id: string;
|
||||
state: "configured-unavailable";
|
||||
diagnostic: {
|
||||
kind: "plugin-verification";
|
||||
reason: import("../plugins/runtime-degraded-state.js").PluginVerificationFailureReason;
|
||||
detail: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Context engine quarantine entry included in health output. */
|
||||
|
||||
@@ -51,6 +51,29 @@ describe("status-overview-rows", () => {
|
||||
expect(findRowValue(rows, "Update restart")).toBe("failed · managed-service-handoff-failed");
|
||||
});
|
||||
|
||||
it("lists plugins quarantined as configured-unavailable", () => {
|
||||
const rows = buildStatusCommandOverviewRows(
|
||||
createStatusCommandOverviewRowsParams({
|
||||
summary: {
|
||||
...createStatusCommandOverviewRowsParams().summary,
|
||||
degradedPlugins: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "unreadable-package-json",
|
||||
detail: "permission denied",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findRowValue(rows, "Degraded plugins")).toBe("warn(1 configured-unavailable · discord)");
|
||||
});
|
||||
|
||||
it("builds status-all overview rows from the shared surface", () => {
|
||||
const rows = buildStatusAllOverviewRows({
|
||||
surface: {
|
||||
|
||||
@@ -115,6 +115,15 @@ export function buildStatusCommandOverviewRows(
|
||||
.join(", ")}`,
|
||||
)
|
||||
: null;
|
||||
const degradedPlugins = params.summary.degradedPlugins ?? [];
|
||||
const degradedPluginsValue =
|
||||
degradedPlugins.length > 0
|
||||
? params.warn(
|
||||
`${degradedPlugins.length} configured-unavailable · ${degradedPlugins
|
||||
.map((plugin) => plugin.pluginId)
|
||||
.join(", ")}`,
|
||||
)
|
||||
: null;
|
||||
const tasksValue = buildStatusTasksValue({
|
||||
summary: params.summary,
|
||||
warn: params.warn,
|
||||
@@ -176,6 +185,7 @@ export function buildStatusCommandOverviewRows(
|
||||
: []),
|
||||
{ Item: "Memory", Value: memoryValue },
|
||||
...(degradedSecretsValue ? [{ Item: "Degraded secrets", Value: degradedSecretsValue }] : []),
|
||||
...(degradedPluginsValue ? [{ Item: "Degraded plugins", Value: degradedPluginsValue }] : []),
|
||||
{ Item: "Plugin compatibility", Value: pluginCompatibilityValue },
|
||||
{ Item: "Probes", Value: probesValue },
|
||||
{ Item: "Events", Value: eventsValue },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Status summary tests cover aggregate status text for channels, sessions, tasks, and audit findings.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js";
|
||||
import { setActiveDegradedSecretOwners } from "../secrets/runtime-degraded-state.js";
|
||||
import type { TaskAuditFinding } from "../tasks/task-registry.audit.js";
|
||||
import type { TaskRecord, TaskRegistrySummary } from "../tasks/task-registry.types.js";
|
||||
@@ -205,6 +206,7 @@ describe("getStatusSummary", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActiveDegradedPlugins([]);
|
||||
setActiveDegradedSecretOwners([]);
|
||||
statusSummaryMocks.taskRegistrySummary = {
|
||||
total: 0,
|
||||
@@ -302,6 +304,76 @@ describe("getStatusSummary", () => {
|
||||
expect(JSON.stringify(summary.degradedSecretOwners)).not.toContain("PRIVATE_REF_ID");
|
||||
});
|
||||
|
||||
it("reports every plugin configured unavailable by startup verification", async () => {
|
||||
setActiveDegradedPlugins([
|
||||
{
|
||||
pluginId: "discord",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "unreadable-package-json",
|
||||
detail: "Could not read /private/plugins/discord/package.json: permission denied",
|
||||
installPath: "/private/plugins/discord",
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "matrix",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "missing-main-entry",
|
||||
detail: "dist/index.js is missing",
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "peer-plugin",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "missing-openclaw-peer-link",
|
||||
detail:
|
||||
"/private/plugins/peer-plugin/node_modules/openclaw points to /private/other/openclaw instead of /private/host/openclaw",
|
||||
installPath: "/private/plugins/peer-plugin",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const summary = await getStatusSummary();
|
||||
|
||||
expect(summary.degradedPlugins).toEqual([
|
||||
{
|
||||
pluginId: "discord",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "unreadable-package-json",
|
||||
detail: "Could not read <plugin-install>/package.json: permission denied",
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "matrix",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "missing-main-entry",
|
||||
detail: "dist/index.js is missing",
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "peer-plugin",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "missing-openclaw-peer-link",
|
||||
detail:
|
||||
'Plugin declares peerDependency "openclaw", but its host peer link is missing or invalid.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(summary.degradedPlugins)).not.toContain("/private/plugins");
|
||||
expect(JSON.stringify(summary.degradedPlugins)).not.toContain("/private/host");
|
||||
});
|
||||
|
||||
it("reuses one reconciled task snapshot for task summaries and audit findings", async () => {
|
||||
const inspectableTasks: TaskRecord[] = [];
|
||||
statusSummaryMocks.inspectableTasks = inspectableTasks;
|
||||
|
||||
@@ -16,6 +16,10 @@ import type { OpenClawConfig } from "../config/types.js";
|
||||
import { listGatewayAgentsBasic } from "../gateway/agent-list.js";
|
||||
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js";
|
||||
import { peekSystemEvents } from "../infra/system-events.js";
|
||||
import {
|
||||
listActiveDegradedPlugins,
|
||||
toPublicPluginVerificationDiagnostic,
|
||||
} from "../plugins/runtime-degraded-state.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { listActiveDegradedSecretOwners } from "../secrets/runtime-degraded-state.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
@@ -574,6 +578,11 @@ export async function getStatusSummary(
|
||||
reason,
|
||||
}),
|
||||
),
|
||||
degradedPlugins: listActiveDegradedPlugins().map(({ pluginId, state, diagnostic }) => ({
|
||||
pluginId,
|
||||
state,
|
||||
diagnostic: toPublicPluginVerificationDiagnostic(diagnostic),
|
||||
})),
|
||||
tasks,
|
||||
taskAudit,
|
||||
...(taskAuditRetainedLost.count > 0 ? { taskAuditRetainedLost } : {}),
|
||||
|
||||
@@ -73,6 +73,15 @@ export type StatusSummary = {
|
||||
paths: string[];
|
||||
reason: string;
|
||||
}>;
|
||||
degradedPlugins?: Array<{
|
||||
pluginId: string;
|
||||
state: "configured-unavailable";
|
||||
diagnostic: {
|
||||
kind: "plugin-verification";
|
||||
reason: import("../plugins/runtime-degraded-state.js").PluginVerificationFailureReason;
|
||||
detail: string;
|
||||
};
|
||||
}>;
|
||||
tasks: TaskRegistrySummary;
|
||||
taskAudit: TaskAuditSummary;
|
||||
taskAuditRetainedLost?: RetainedLostTaskAuditSummary;
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js";
|
||||
import type { PluginRegistryParams } from "../plugins/registry-types.js";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import {
|
||||
findActiveDegradedPlugin,
|
||||
formatPluginVerificationDiagnostic,
|
||||
} from "../plugins/runtime-degraded-state.js";
|
||||
import {
|
||||
pinActivePluginChannelRegistry,
|
||||
pinActivePluginSessionExtensionRegistry,
|
||||
@@ -74,6 +78,16 @@ function logGatewayPluginDiagnostics(params: {
|
||||
log: Pick<GatewayPluginBootstrapLog, "error" | "info">;
|
||||
}) {
|
||||
for (const diag of params.diagnostics) {
|
||||
const degradedPlugin = diag.pluginId ? findActiveDegradedPlugin(diag.pluginId) : undefined;
|
||||
// Startup preflight already emitted this typed owner diagnostic. Keep it
|
||||
// in the registry for health/status, but do not print it a second time.
|
||||
if (
|
||||
diag.code === "plugin-verification" &&
|
||||
degradedPlugin &&
|
||||
diag.message === formatPluginVerificationDiagnostic(degradedPlugin.diagnostic)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const details = [
|
||||
diag.pluginId ? `plugin=${diag.pluginId}` : null,
|
||||
diag.source ? `source=${diag.source}` : null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { PluginDiagnostic } from "../plugins/manifest-types.js";
|
||||
import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActiveDegradedPlugins } from "../plugins/runtime-degraded-state.js";
|
||||
import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js";
|
||||
import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.test-fixtures.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
@@ -423,6 +424,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActiveDegradedPlugins([]);
|
||||
serverPluginsModule.clearFallbackGatewayContext();
|
||||
clearGatewaySubagentRuntime();
|
||||
runtimeRegistryModule.resetPluginRuntimeStateForTest();
|
||||
@@ -447,6 +449,41 @@ describe("loadGatewayPlugins", () => {
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not re-log a quarantined plugin verification diagnostic", () => {
|
||||
const diagnostic: PluginDiagnostic = {
|
||||
level: "error",
|
||||
code: "plugin-verification",
|
||||
pluginId: "broken-payload",
|
||||
source: "/tmp/broken-payload/index.ts",
|
||||
message: "configured plugin payload verification failed (missing-main-entry): missing",
|
||||
};
|
||||
const distinctDiagnostic: PluginDiagnostic = {
|
||||
...diagnostic,
|
||||
message: "configured plugin payload verification failed (missing-package-json): missing",
|
||||
};
|
||||
const registry = createRegistry([diagnostic, distinctDiagnostic]);
|
||||
loadOpenClawPlugins.mockReturnValue(registry);
|
||||
setActiveDegradedPlugins([
|
||||
{
|
||||
pluginId: "broken-payload",
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: "missing-main-entry",
|
||||
detail: "missing",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const log = loadGatewayStartupPluginsForTest();
|
||||
|
||||
expect(log.error).toHaveBeenCalledOnce();
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
"[plugins] configured plugin payload verification failed (missing-package-json): missing (plugin=broken-payload, source=/tmp/broken-payload/index.ts)",
|
||||
);
|
||||
expect(registry.diagnostics).toEqual([diagnostic, distinctDiagnostic]);
|
||||
});
|
||||
|
||||
test("loads only gateway startup plugin ids", () => {
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
||||
loadGatewayPluginsForTest();
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/** Real Gateway readiness coverage for configured plugin payload quarantine. */
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runPluginPayloadSmokeCheck } from "../cli/update-cli/plugin-payload-validation.js";
|
||||
import {
|
||||
buildDegradedPluginsFromVerificationFailures,
|
||||
listActiveDegradedPlugins,
|
||||
setActiveDegradedPlugins,
|
||||
} from "../plugins/runtime-degraded-state.js";
|
||||
import {
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
setTestPluginRegistry,
|
||||
startGatewayServer,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
describe("Gateway startup plugin quarantine", () => {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
setActiveDegradedPlugins([]);
|
||||
delete (globalThis as Record<string, unknown>).brokenPluginImported;
|
||||
delete (globalThis as Record<string, unknown>).selectedPluginImported;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reaches readiness without importing one broken configured plugin", async () => {
|
||||
const pluginId = "broken-payload";
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-quarantined-plugin-"));
|
||||
tempDirs.push(pluginRoot);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: pluginId,
|
||||
type: "commonjs",
|
||||
main: "./missing-main.cjs",
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: pluginId,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, "index.cjs"),
|
||||
"globalThis.brokenPluginImported = true; module.exports = { id: 'broken-payload', register() {} };",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const smoke = await runPluginPayloadSmokeCheck({
|
||||
records: {
|
||||
[pluginId]: {
|
||||
source: "npm",
|
||||
spec: pluginId,
|
||||
installPath: pluginRoot,
|
||||
},
|
||||
},
|
||||
env: process.env,
|
||||
});
|
||||
expect(smoke.failures).toMatchObject([
|
||||
{ pluginId, reason: "missing-main-entry", installPath: pluginRoot },
|
||||
]);
|
||||
setActiveDegradedPlugins(buildDegradedPluginsFromVerificationFailures(smoke.failures));
|
||||
|
||||
const { loadOpenClawPlugins } =
|
||||
await vi.importActual<typeof import("../plugins/loader.js")>("../plugins/loader.js");
|
||||
const pluginConfig = {
|
||||
enabled: true,
|
||||
load: { paths: [pluginRoot] },
|
||||
allow: [pluginId],
|
||||
entries: { [pluginId]: { enabled: true } },
|
||||
};
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: { plugins: pluginConfig },
|
||||
onlyPluginIds: [pluginId],
|
||||
});
|
||||
expect(registry.plugins.find((plugin) => plugin.id === pluginId)).toMatchObject({
|
||||
status: "error",
|
||||
activated: false,
|
||||
failurePhase: "validation",
|
||||
activationReason: "configured-unavailable: missing-main-entry",
|
||||
});
|
||||
expect(registry.diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
pluginId,
|
||||
code: "plugin-verification",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
registry.diagnostics.find((diagnostic) => diagnostic.pluginId === pluginId)?.message,
|
||||
).not.toContain(pluginRoot);
|
||||
expect((globalThis as Record<string, unknown>).brokenPluginImported).toBeUndefined();
|
||||
|
||||
setTestPluginRegistry(registry);
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
gateway: { mode: "local", bind: "loopback", auth: { mode: "none" } },
|
||||
plugins: pluginConfig,
|
||||
});
|
||||
|
||||
const port = await getFreePort();
|
||||
server = await startGatewayServer(port, { auth: { mode: "none" } });
|
||||
const ready = await fetch(`http://127.0.0.1:${port}/readyz`);
|
||||
|
||||
expect(ready.status).toBe(200);
|
||||
await expect(ready.json()).resolves.toMatchObject({ ready: true });
|
||||
expect((globalThis as Record<string, unknown>).brokenPluginImported).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not quarantine a healthy explicit root that shadows a broken install with the same id", async () => {
|
||||
const pluginId = "shadowed-payload";
|
||||
const brokenRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-broken-install-"));
|
||||
const selectedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-selected-plugin-"));
|
||||
tempDirs.push(brokenRoot, selectedRoot);
|
||||
fs.writeFileSync(
|
||||
path.join(brokenRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: pluginId,
|
||||
type: "commonjs",
|
||||
main: "./missing-main.cjs",
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(selectedRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: pluginId,
|
||||
type: "commonjs",
|
||||
main: "./index.cjs",
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(selectedRoot, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: pluginId,
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(selectedRoot, "index.cjs"),
|
||||
"globalThis.selectedPluginImported = true; module.exports = { id: 'shadowed-payload', register() {} };",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const smoke = await runPluginPayloadSmokeCheck({
|
||||
records: {
|
||||
[pluginId]: { source: "npm", spec: pluginId, installPath: brokenRoot },
|
||||
},
|
||||
env: process.env,
|
||||
});
|
||||
setActiveDegradedPlugins(buildDegradedPluginsFromVerificationFailures(smoke.failures));
|
||||
|
||||
const { loadOpenClawPlugins } =
|
||||
await vi.importActual<typeof import("../plugins/loader.js")>("../plugins/loader.js");
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
load: { paths: [selectedRoot] },
|
||||
allow: [pluginId],
|
||||
entries: { [pluginId]: { enabled: true } },
|
||||
},
|
||||
},
|
||||
onlyPluginIds: [pluginId],
|
||||
});
|
||||
|
||||
expect(registry.plugins.find((plugin) => plugin.id === pluginId)?.status).toBe("loaded");
|
||||
expect((globalThis as Record<string, unknown>).selectedPluginImported).toBe(true);
|
||||
expect(listActiveDegradedPlugins()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the broken install visible when its explicit override fails to load", async () => {
|
||||
const pluginId = "failed-shadow";
|
||||
const brokenRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-broken-install-"));
|
||||
const selectedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-selected-plugin-"));
|
||||
tempDirs.push(brokenRoot, selectedRoot);
|
||||
fs.writeFileSync(
|
||||
path.join(brokenRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: pluginId,
|
||||
type: "commonjs",
|
||||
main: "./missing-main.cjs",
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(selectedRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: pluginId,
|
||||
type: "commonjs",
|
||||
main: "./index.cjs",
|
||||
openclaw: { extensions: ["./index.cjs"] },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(selectedRoot, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: pluginId,
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(selectedRoot, "index.cjs"),
|
||||
"throw new Error('import failed');",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const smoke = await runPluginPayloadSmokeCheck({
|
||||
records: {
|
||||
[pluginId]: { source: "npm", spec: pluginId, installPath: brokenRoot },
|
||||
},
|
||||
env: process.env,
|
||||
});
|
||||
setActiveDegradedPlugins(buildDegradedPluginsFromVerificationFailures(smoke.failures));
|
||||
|
||||
const { loadOpenClawPlugins } =
|
||||
await vi.importActual<typeof import("../plugins/loader.js")>("../plugins/loader.js");
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
load: { paths: [selectedRoot] },
|
||||
allow: [pluginId],
|
||||
entries: { [pluginId]: { enabled: true } },
|
||||
},
|
||||
},
|
||||
onlyPluginIds: [pluginId],
|
||||
});
|
||||
|
||||
expect(registry.plugins.find((plugin) => plugin.id === pluginId)?.status).toBe("error");
|
||||
expect(listActiveDegradedPlugins()).toMatchObject([
|
||||
{ pluginId, diagnostic: { installPath: brokenRoot } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import type { PluginBundleFormat, PluginDiagnosticCode, PluginFormat } from "./m
|
||||
import type { PluginManifestContracts } from "./manifest.js";
|
||||
import { isPluginLifecycleTraceEnabled } from "./plugin-lifecycle-trace.js";
|
||||
import type { PluginRecord, PluginRegistry } from "./registry.js";
|
||||
import {
|
||||
formatPluginVerificationDiagnostic,
|
||||
type DegradedPlugin,
|
||||
} from "./runtime-degraded-state.js";
|
||||
import type { PluginLogger } from "./types.js";
|
||||
|
||||
/** Builds the registry record shape shared by plugin loading, status, and diagnostics. */
|
||||
@@ -95,6 +99,31 @@ export function markPluginActivationDisabled(record: PluginRecord, reason?: stri
|
||||
record.activationReason = reason;
|
||||
}
|
||||
|
||||
/** Records a boot-time payload quarantine without importing or activating the plugin. */
|
||||
export function recordPluginConfiguredUnavailable(params: {
|
||||
registry: PluginRegistry;
|
||||
record: PluginRecord;
|
||||
seenIds: Map<string, PluginRecord["origin"]>;
|
||||
origin: PluginRecord["origin"];
|
||||
degradedPlugin: DegradedPlugin;
|
||||
}): void {
|
||||
const error = formatPluginVerificationDiagnostic(params.degradedPlugin.diagnostic);
|
||||
params.record.status = "error";
|
||||
params.record.error = error;
|
||||
params.record.failurePhase = "validation";
|
||||
params.record.activated = false;
|
||||
params.record.activationReason = `configured-unavailable: ${params.degradedPlugin.diagnostic.reason}`;
|
||||
params.registry.plugins.push(params.record);
|
||||
params.seenIds.set(params.record.id, params.origin);
|
||||
params.registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: params.record.id,
|
||||
source: params.record.source,
|
||||
code: "plugin-verification",
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
|
||||
/** Joins auto-enable reasons into the single registry field shown by status surfaces. */
|
||||
export function formatAutoEnabledActivationReason(
|
||||
reasons: readonly string[] | undefined,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
formatAutoEnabledActivationReason,
|
||||
formatMissingPluginRegisterError,
|
||||
markPluginActivationDisabled,
|
||||
recordPluginConfiguredUnavailable,
|
||||
recordPluginError,
|
||||
} from "./loader-records.js";
|
||||
import { resolvePluginRegistrationPlan } from "./loader-registration-plan.js";
|
||||
@@ -50,6 +51,11 @@ import {
|
||||
resolvePluginRuntimeArtifact,
|
||||
} from "./plugin-runtime-artifact-resolution.js";
|
||||
import type { createPluginRegistry, PluginRecord } from "./registry.js";
|
||||
import {
|
||||
clearActiveDegradedPlugin,
|
||||
degradedPluginMatchesRoot,
|
||||
findActiveDegradedPlugin,
|
||||
} from "./runtime-degraded-state.js";
|
||||
import { recordImportedPluginId } from "./runtime.js";
|
||||
import { hasKind, kindsEqual } from "./slots.js";
|
||||
import type { OpenClawPluginModule, PluginLogger } from "./types.js";
|
||||
@@ -145,6 +151,26 @@ export function loadRuntimePluginCandidate(params: {
|
||||
activationState,
|
||||
});
|
||||
applyPluginManifestRecordDetails(record, manifestRecord);
|
||||
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
||||
const degradedPluginForId = findActiveDegradedPlugin(pluginId);
|
||||
const degradedPlugin =
|
||||
degradedPluginForId && degradedPluginMatchesRoot(degradedPluginForId, pluginRoot)
|
||||
? degradedPluginForId
|
||||
: undefined;
|
||||
const clearMismatchedQuarantineAfterLoad =
|
||||
enableState.enabled && Boolean(degradedPluginForId) && !degradedPlugin;
|
||||
if (enableState.enabled && degradedPlugin) {
|
||||
// Startup verification owns this boot-stable quarantine. Return before
|
||||
// artifact resolution so no top-level plugin code can execute this boot.
|
||||
recordPluginConfiguredUnavailable({
|
||||
registry,
|
||||
record,
|
||||
seenIds: state.seenIds,
|
||||
origin: candidate.origin,
|
||||
degradedPlugin,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const localSetupBasePolicyBlock = resolveManifestOwnerBasePolicyBlock({
|
||||
plugin: { id: pluginId },
|
||||
normalizedConfig: context.normalized,
|
||||
@@ -186,7 +212,6 @@ export function loadRuntimePluginCandidate(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
||||
const runtimeCandidateEntry = resolvePluginRuntimeArtifact({
|
||||
pluginId,
|
||||
entryKind: "runtime",
|
||||
@@ -507,6 +532,11 @@ export function loadRuntimePluginCandidate(params: {
|
||||
registry.plugins.push(record);
|
||||
state.seenIds.set(pluginId, candidate.origin);
|
||||
transaction.commit({ activate: context.shouldActivate });
|
||||
if (clearMismatchedQuarantineAfterLoad) {
|
||||
// Plugin ids can intentionally shadow an installed source via load.paths.
|
||||
// Clear stale install state only after the selected override registers.
|
||||
clearActiveDegradedPlugin(pluginId);
|
||||
}
|
||||
} catch (error) {
|
||||
transaction.rollback();
|
||||
recordPluginError({
|
||||
|
||||
@@ -18,7 +18,7 @@ export type PluginBundleFormat = "codex" | "claude" | "cursor";
|
||||
* Closed classification codes for plugin diagnostics. Health surfaces branch
|
||||
* on these instead of matching freeform diagnostic message text.
|
||||
*/
|
||||
export type PluginDiagnosticCode = "channel-setup-failure";
|
||||
export type PluginDiagnosticCode = "channel-setup-failure" | "plugin-verification";
|
||||
|
||||
/** Diagnostic emitted while discovering or validating plugins. */
|
||||
export type PluginDiagnostic = {
|
||||
|
||||
@@ -52,6 +52,67 @@ describe("plugin peer links", () => {
|
||||
expect(messages.join("\n")).toContain('Linked peerDependency "openclaw"');
|
||||
});
|
||||
|
||||
it("reports one unreadable package and continues repairing its sibling", async () => {
|
||||
const npmRoot = makeTempDir();
|
||||
const unreadableDir = path.join(npmRoot, "node_modules", "bad-plugin");
|
||||
const peerDir = path.join(npmRoot, "node_modules", "peer-plugin");
|
||||
fs.mkdirSync(unreadableDir, { recursive: true });
|
||||
fs.mkdirSync(peerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(unreadableDir, "package.json"), "{", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(peerDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "peer-plugin",
|
||||
peerDependencies: { openclaw: ">=2026.0.0" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const failures: Array<{ error: unknown; packageDir: string }> = [];
|
||||
|
||||
const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
logger: {},
|
||||
onPackageReadError: (error, packageDir) => failures.push({ error, packageDir }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ checked: 1, attempted: 1, repaired: 1, skipped: 1 });
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]?.packageDir).toBe(unreadableDir);
|
||||
expect(failures[0]?.error).toBeInstanceOf(SyntaxError);
|
||||
expect(fs.lstatSync(path.join(peerDir, "node_modules", "openclaw")).isSymbolicLink()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports one unreadable package and continues auditing its sibling", async () => {
|
||||
const npmRoot = makeTempDir();
|
||||
const unreadableDir = path.join(npmRoot, "node_modules", "bad-plugin");
|
||||
const peerDir = path.join(npmRoot, "node_modules", "peer-plugin");
|
||||
fs.mkdirSync(unreadableDir, { recursive: true });
|
||||
fs.mkdirSync(peerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(unreadableDir, "package.json"), "{", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(peerDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "peer-plugin",
|
||||
peerDependencies: { openclaw: ">=2026.0.0" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const failures: Array<{ error: unknown; packageDir: string }> = [];
|
||||
|
||||
const result = await auditOpenClawPeerDependenciesInManagedNpmRoot({
|
||||
npmRoot,
|
||||
onPackageReadError: (error, packageDir) => failures.push({ error, packageDir }),
|
||||
});
|
||||
|
||||
expect(result.checked).toBe(1);
|
||||
expect(result.broken).toBe(1);
|
||||
expect(result.issues[0]?.packageName).toBe("peer-plugin");
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(failures[0]?.packageDir).toBe(unreadableDir);
|
||||
});
|
||||
|
||||
it("audits missing managed npm openclaw peer links without relinking", async () => {
|
||||
const npmRoot = makeTempDir();
|
||||
const packageDir = path.join(npmRoot, "node_modules", "peer-plugin");
|
||||
|
||||
@@ -340,13 +340,24 @@ export async function linkOpenClawPeerDependencies(params: {
|
||||
export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
|
||||
npmRoot: string;
|
||||
logger: PluginPeerLinkLogger;
|
||||
onPackageReadError?: (error: unknown, packageDir: string) => void;
|
||||
}): Promise<RelinkManagedNpmRootResult> {
|
||||
let checked = 0;
|
||||
let attempted = 0;
|
||||
let repaired = 0;
|
||||
let skipped = 0;
|
||||
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
|
||||
const peerDependencies = await readPackagePeerDependencies(packageDir);
|
||||
let peerDependencies: Record<string, string>;
|
||||
try {
|
||||
peerDependencies = await readPackagePeerDependencies(packageDir);
|
||||
} catch (error) {
|
||||
if (!params.onPackageReadError) {
|
||||
throw error;
|
||||
}
|
||||
params.onPackageReadError(error, packageDir);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (!Object.hasOwn(peerDependencies, "openclaw")) {
|
||||
continue;
|
||||
}
|
||||
@@ -365,6 +376,7 @@ export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: {
|
||||
|
||||
export async function auditOpenClawPeerDependenciesInManagedNpmRoot(params: {
|
||||
npmRoot: string;
|
||||
onPackageReadError?: (error: unknown, packageDir: string) => void;
|
||||
}): Promise<AuditManagedNpmRootResult> {
|
||||
const hostRoot = resolveOpenClawPackageRootSync({
|
||||
argv1: process.argv[1],
|
||||
@@ -378,7 +390,16 @@ export async function auditOpenClawPeerDependenciesInManagedNpmRoot(params: {
|
||||
let checked = 0;
|
||||
const issues: OpenClawPeerLinkAuditIssue[] = [];
|
||||
for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) {
|
||||
const peerDependencies = await readPackagePeerDependencies(packageDir);
|
||||
let peerDependencies: Record<string, string>;
|
||||
try {
|
||||
peerDependencies = await readPackagePeerDependencies(packageDir);
|
||||
} catch (error) {
|
||||
if (!params.onPackageReadError) {
|
||||
throw error;
|
||||
}
|
||||
params.onPackageReadError(error, packageDir);
|
||||
continue;
|
||||
}
|
||||
if (!Object.hasOwn(peerDependencies, "openclaw")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/** Boot-stable quarantine state for configured plugins whose payload failed verification. */
|
||||
|
||||
export type PluginVerificationFailureReason =
|
||||
| "missing-install-path"
|
||||
| "missing-package-dir"
|
||||
| "missing-package-json"
|
||||
| "unreadable-package-json"
|
||||
| "invalid-package-json"
|
||||
| "missing-bundle-manifest"
|
||||
| "invalid-bundle-manifest"
|
||||
| "missing-main-entry"
|
||||
| "missing-extension-entry"
|
||||
| "missing-openclaw-peer-link";
|
||||
|
||||
type PluginVerificationDiagnostic = {
|
||||
kind: "plugin-verification";
|
||||
reason: PluginVerificationFailureReason;
|
||||
detail: string;
|
||||
installPath?: string;
|
||||
};
|
||||
|
||||
type PublicPluginVerificationDiagnostic = Pick<
|
||||
PluginVerificationDiagnostic,
|
||||
"kind" | "reason" | "detail"
|
||||
>;
|
||||
|
||||
export type DegradedPlugin = {
|
||||
pluginId: string;
|
||||
state: "configured-unavailable";
|
||||
diagnostic: PluginVerificationDiagnostic;
|
||||
};
|
||||
|
||||
type PluginVerificationFailure = {
|
||||
pluginId: string;
|
||||
installPath?: string;
|
||||
reason: PluginVerificationFailureReason;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
let activeDegradedPlugins: DegradedPlugin[] = [];
|
||||
|
||||
function cloneDegradedPlugin(plugin: DegradedPlugin): DegradedPlugin {
|
||||
return {
|
||||
...plugin,
|
||||
diagnostic: { ...plugin.diagnostic },
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts verified ownership failures into the quarantine state used for this boot. */
|
||||
export function buildDegradedPluginsFromVerificationFailures(
|
||||
failures: readonly PluginVerificationFailure[],
|
||||
): DegradedPlugin[] {
|
||||
const degraded = new Map<string, DegradedPlugin>();
|
||||
for (const failure of failures) {
|
||||
// One owner produces one quarantine record even when several payload files
|
||||
// fail verification; startup already emits every finding before this fold.
|
||||
if (degraded.has(failure.pluginId)) {
|
||||
continue;
|
||||
}
|
||||
degraded.set(failure.pluginId, {
|
||||
pluginId: failure.pluginId,
|
||||
state: "configured-unavailable",
|
||||
diagnostic: {
|
||||
kind: "plugin-verification",
|
||||
reason: failure.reason,
|
||||
detail: failure.detail,
|
||||
...(failure.installPath ? { installPath: failure.installPath } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...degraded.values()];
|
||||
}
|
||||
|
||||
/** Replaces the process-local quarantine snapshot established before Gateway plugin loading. */
|
||||
export function setActiveDegradedPlugins(plugins: readonly DegradedPlugin[]): void {
|
||||
activeDegradedPlugins = plugins.map(cloneDegradedPlugin);
|
||||
}
|
||||
|
||||
export function listActiveDegradedPlugins(): DegradedPlugin[] {
|
||||
return activeDegradedPlugins.map(cloneDegradedPlugin);
|
||||
}
|
||||
|
||||
export function findActiveDegradedPlugin(pluginId: string): DegradedPlugin | undefined {
|
||||
const plugin = activeDegradedPlugins.find((entry) => entry.pluginId === pluginId);
|
||||
return plugin ? cloneDegradedPlugin(plugin) : undefined;
|
||||
}
|
||||
|
||||
/** Drops a verification failure that belongs to a different selected plugin root. */
|
||||
export function clearActiveDegradedPlugin(pluginId: string): void {
|
||||
activeDegradedPlugins = activeDegradedPlugins.filter((entry) => entry.pluginId !== pluginId);
|
||||
}
|
||||
|
||||
/** Matches install-record and discovered roots across symlink/path aliases. */
|
||||
export function degradedPluginMatchesRoot(plugin: DegradedPlugin, rootDir: string): boolean {
|
||||
const installPath = plugin.diagnostic.installPath;
|
||||
if (!installPath) {
|
||||
return false;
|
||||
}
|
||||
const canonicalize = (value: string) => {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
};
|
||||
return canonicalize(installPath) === canonicalize(rootDir);
|
||||
}
|
||||
|
||||
/** Removes the known private install root before diagnostics leave the Gateway process. */
|
||||
export function toPublicPluginVerificationDiagnostic(
|
||||
diagnostic: PluginVerificationDiagnostic,
|
||||
): PublicPluginVerificationDiagnostic {
|
||||
const detail =
|
||||
diagnostic.reason === "missing-openclaw-peer-link"
|
||||
? 'Plugin declares peerDependency "openclaw", but its host peer link is missing or invalid.'
|
||||
: diagnostic.installPath
|
||||
? diagnostic.detail.replaceAll(diagnostic.installPath, "<plugin-install>")
|
||||
: diagnostic.detail;
|
||||
return {
|
||||
kind: diagnostic.kind,
|
||||
reason: diagnostic.reason,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatPluginVerificationDiagnostic(
|
||||
diagnostic: PluginVerificationDiagnostic,
|
||||
): string {
|
||||
const publicDiagnostic = toPublicPluginVerificationDiagnostic(diagnostic);
|
||||
return `configured plugin payload verification failed (${publicDiagnostic.reason}): ${publicDiagnostic.detail}`;
|
||||
}
|
||||
Reference in New Issue
Block a user