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:
Vito Cappello
2026-07-17 22:50:50 -04:00
committed by GitHub
parent 73cf9ee908
commit cd1ab40632
29 changed files with 1724 additions and 131 deletions
+7
View File
@@ -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();
});
+191 -37
View File
@@ -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() };
+11
View File
@@ -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");
});
});
+84 -5
View File
@@ -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);
}
+118
View File
@@ -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",
},
]);
});
});
+6 -1
View File
@@ -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
View File
@@ -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 {
+9
View File
@@ -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. */
+23
View File
@@ -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: {
+10
View File
@@ -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 },
+72
View File
@@ -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;
+9
View File
@@ -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 } : {}),
+9
View File
@@ -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;
+14
View File
@@ -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,
+37
View File
@@ -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 } },
]);
});
});
+29
View File
@@ -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,
+31 -1
View File
@@ -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({
+1 -1
View File
@@ -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 = {
+61
View File
@@ -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");
+23 -2
View File
@@ -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;
}
+134
View File
@@ -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}`;
}