diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 24a89ff536d4..97807a67db54 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 `, 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 diff --git a/src/cli/update-cli/active-plugin-payload-validation.ts b/src/cli/update-cli/active-plugin-payload-validation.ts new file mode 100644 index 000000000000..ef6e1d0c20f8 --- /dev/null +++ b/src/cli/update-cli/active-plugin-payload-validation.ts @@ -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; + env: NodeJS.ProcessEnv; +}): Promise { + 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; +}): Record { + const normalizedPluginConfig = normalizePluginsConfig(params.cfg.plugins); + const filtered: Record = {}; + 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; +} diff --git a/src/cli/update-cli/plugin-payload-validation.ts b/src/cli/update-cli/plugin-payload-validation.ts index c724fa7f9e6d..ddc7a08768f1 100644 --- a/src/cli/update-cli/plugin-payload-validation.ts +++ b/src/cli/update-cli/plugin-payload-validation.ts @@ -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[]; }; diff --git a/src/cli/update-cli/post-core-plugin-convergence.test.ts b/src/cli/update-cli/post-core-plugin-convergence.test.ts index 69985c1e99cb..c7dc5f86186c 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.test.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.test.ts @@ -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({ diff --git a/src/cli/update-cli/post-core-plugin-convergence.ts b/src/cli/update-cli/post-core-plugin-convergence.ts index 2e56e83c263e..043768635e70 100644 --- a/src/cli/update-cli/post-core-plugin-convergence.ts +++ b/src/cli/update-cli/post-core-plugin-convergence.ts @@ -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, + ): Set => + 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; -}): Record { - const normalizedPluginConfig = normalizePluginsConfig(params.cfg.plugins); - const filtered: Record = {}; - 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[]; diff --git a/src/commands/doctor-config-preflight.state-migration.test.ts b/src/commands/doctor-config-preflight.state-migration.test.ts index a3bbad01e2f8..853b347113d8 100644 --- a/src/commands/doctor-config-preflight.state-migration.test.ts +++ b/src/commands/doctor-config-preflight.state-migration.test.ts @@ -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; }; @@ -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(); }); diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index c6ae48b05920..32d82146b3aa 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -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 { +}) { 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 { + 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 { + 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 { diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts index bc38adeb729c..35b56c9e34f3 100644 --- a/src/commands/doctor-gateway-health.test.ts +++ b/src/commands/doctor-gateway-health.test.ts @@ -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() }; diff --git a/src/commands/doctor-gateway-health.ts b/src/commands/doctor-gateway-health.ts index ff31036c483c..6d386dbbfe82 100644 --- a/src/commands/doctor-gateway-health.ts +++ b/src/commands/doctor-gateway-health.ts @@ -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", diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index c0470d5cf4ba..eebda9f95c75 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -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"); + }); }); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index 9b5ef4636b6e..0044863fdb22 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -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 | null { const parsed = tryReadJsonSync(filePath); return isRecord(parsed) ? parsed : null; @@ -357,8 +371,19 @@ export async function maybeRepairManagedNpmOpenClawPeerLinks( ): Promise { 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 { +): 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); } diff --git a/src/commands/health.plugins.test.ts b/src/commands/health.plugins.test.ts new file mode 100644 index 000000000000..aa56da1d7487 --- /dev/null +++ b/src/commands/health.plugins.test.ts @@ -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 /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", + }, + ]); + }); +}); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index cac49b94c1ac..f604ca0a0c88 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -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 = []; diff --git a/src/commands/health.ts b/src/commands/health.ts index e918846a2178..a6ecf18276ac 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -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 { diff --git a/src/commands/health.types.ts b/src/commands/health.types.ts index 4ef307c3c932..5fcfab0cf96d 100644 --- a/src/commands/health.types.ts +++ b/src/commands/health.types.ts @@ -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. */ diff --git a/src/commands/status-overview-rows.test.ts b/src/commands/status-overview-rows.test.ts index d3f794e5ad76..040ebf48f44a 100644 --- a/src/commands/status-overview-rows.test.ts +++ b/src/commands/status-overview-rows.test.ts @@ -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: { diff --git a/src/commands/status-overview-rows.ts b/src/commands/status-overview-rows.ts index ae1d83b15b1f..d08115d3029a 100644 --- a/src/commands/status-overview-rows.ts +++ b/src/commands/status-overview-rows.ts @@ -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 }, diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index a3c4071d70f9..2242f922cf2e 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -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 /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; diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index a7291999bb3e..14daa693ad1c 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -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 } : {}), diff --git a/src/commands/status.types.ts b/src/commands/status.types.ts index 848d6f587bc8..8708c39d2742 100644 --- a/src/commands/status.types.ts +++ b/src/commands/status.types.ts @@ -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; diff --git a/src/gateway/server-plugin-bootstrap.ts b/src/gateway/server-plugin-bootstrap.ts index 7147175f97f8..a472a1cce265 100644 --- a/src/gateway/server-plugin-bootstrap.ts +++ b/src/gateway/server-plugin-bootstrap.ts @@ -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; }) { 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, diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 03d43ab80725..4e161949d481 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -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(); diff --git a/src/gateway/server-startup-plugin-quarantine.test.ts b/src/gateway/server-startup-plugin-quarantine.test.ts new file mode 100644 index 000000000000..f92405264adf --- /dev/null +++ b/src/gateway/server-startup-plugin-quarantine.test.ts @@ -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> | undefined; + const tempDirs: string[] = []; + + afterEach(async () => { + await server?.close(); + server = undefined; + setActiveDegradedPlugins([]); + delete (globalThis as Record).brokenPluginImported; + delete (globalThis as Record).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("../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).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).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("../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).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("../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 } }, + ]); + }); +}); diff --git a/src/plugins/loader-records.ts b/src/plugins/loader-records.ts index fa860087022c..3495f3d324df 100644 --- a/src/plugins/loader-records.ts +++ b/src/plugins/loader-records.ts @@ -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; + 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, diff --git a/src/plugins/loader-runtime-candidate.ts b/src/plugins/loader-runtime-candidate.ts index 9fdd1911b2f8..bfe7218bc2ba 100644 --- a/src/plugins/loader-runtime-candidate.ts +++ b/src/plugins/loader-runtime-candidate.ts @@ -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({ diff --git a/src/plugins/manifest-types.ts b/src/plugins/manifest-types.ts index 272ceb16fcf7..491d4c2e7846 100644 --- a/src/plugins/manifest-types.ts +++ b/src/plugins/manifest-types.ts @@ -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 = { diff --git a/src/plugins/plugin-peer-link.test.ts b/src/plugins/plugin-peer-link.test.ts index 45609bbfd02e..e5ea0959746e 100644 --- a/src/plugins/plugin-peer-link.test.ts +++ b/src/plugins/plugin-peer-link.test.ts @@ -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"); diff --git a/src/plugins/plugin-peer-link.ts b/src/plugins/plugin-peer-link.ts index 10381b153ae8..4c7408125a45 100644 --- a/src/plugins/plugin-peer-link.ts +++ b/src/plugins/plugin-peer-link.ts @@ -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 { 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; + 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 { 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; + try { + peerDependencies = await readPackagePeerDependencies(packageDir); + } catch (error) { + if (!params.onPackageReadError) { + throw error; + } + params.onPackageReadError(error, packageDir); + continue; + } if (!Object.hasOwn(peerDependencies, "openclaw")) { continue; } diff --git a/src/plugins/runtime-degraded-state.ts b/src/plugins/runtime-degraded-state.ts new file mode 100644 index 000000000000..bfb0a1c1fd5d --- /dev/null +++ b/src/plugins/runtime-degraded-state.ts @@ -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(); + 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, "") + : 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}`; +}