diff --git a/src/channels/plugins/module-loader.test.ts b/src/channels/plugins/module-loader.test.ts index 099abca04a25..6a51ed74ed2a 100644 --- a/src/channels/plugins/module-loader.test.ts +++ b/src/channels/plugins/module-loader.test.ts @@ -36,6 +36,23 @@ describe("channel plugin module loader helpers", () => { expect(resolveExistingPluginModulePath(rootDir, "./src/checker")).toBe(expectedPath); }); + it("preserves explicit JavaScript plugin module specifiers", () => { + const rootDir = tempDirs.make("openclaw-channel-module-loader-"); + const expectedPath = path.join(rootDir, "checker.js"); + fs.writeFileSync(expectedPath, "export const ok = true;\n", "utf8"); + + expect(resolveExistingPluginModulePath(rootDir, "./checker.js")).toBe(expectedPath); + }); + + it("resolves plugin module directories through their index", () => { + const rootDir = tempDirs.make("openclaw-channel-module-loader-"); + const expectedPath = path.join(rootDir, "checker", "index.js"); + fs.mkdirSync(path.dirname(expectedPath), { recursive: true }); + fs.writeFileSync(expectedPath, "export const ok = true;\n", "utf8"); + + expect(resolveExistingPluginModulePath(rootDir, "./checker")).toBe(expectedPath); + }); + it("detects JavaScript module paths case-insensitively", () => { expect(isJavaScriptModulePath("/tmp/entry.js")).toBe(true); expect(isJavaScriptModulePath("/tmp/entry.MJS")).toBe(true); diff --git a/src/channels/plugins/module-loader.ts b/src/channels/plugins/module-loader.ts index cb4383845920..4d70e58bc53c 100644 --- a/src/channels/plugins/module-loader.ts +++ b/src/channels/plugins/module-loader.ts @@ -7,6 +7,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { describeRootFileOpenFailure, openRootFileSync } from "../../infra/boundary-file-read.js"; +import { hasErrnoCode } from "../../infra/errno.js"; import { isJavaScriptModulePath } from "../../plugins/native-module-require.js"; import { getCachedPluginModuleLoader, @@ -15,6 +16,7 @@ import { const nodeRequire = createRequire(import.meta.url); const SOURCE_MODULE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]); +const SOURCE_MODULE_RESOLUTION_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const; const jitiLoaders: PluginModuleLoaderCache = new Map(); function hasNativeSourceRequireHook(modulePath: string): boolean { @@ -64,34 +66,35 @@ function loadModule(modulePath: string): unknown { } } -function resolvePluginModuleCandidates(rootDir: string, specifier: string): string[] { +function resolveSourceModuleCandidates(rootDir: string, specifier: string): string[] { const normalizedSpecifier = specifier.replace(/\\/g, "/"); const resolvedPath = path.resolve(rootDir, normalizedSpecifier); - const ext = path.extname(resolvedPath); - if (ext) { - return [resolvedPath]; + if (path.extname(resolvedPath)) { + return []; } - return [ - resolvedPath, - `${resolvedPath}.ts`, - `${resolvedPath}.mts`, - `${resolvedPath}.js`, - `${resolvedPath}.mjs`, - `${resolvedPath}.cts`, - `${resolvedPath}.cjs`, - ]; + return SOURCE_MODULE_RESOLUTION_EXTENSIONS.map((extension) => `${resolvedPath}${extension}`); } /** * Resolves a plugin-relative module specifier to an existing candidate path. */ export function resolveExistingPluginModulePath(rootDir: string, specifier: string): string { - for (const candidate of resolvePluginModuleCandidates(rootDir, specifier)) { + const resolvedPath = path.resolve(rootDir, specifier.replace(/\\/g, "/")); + try { + // Match Node package semantics for explicit files, extensionless JavaScript, + // package mains, and directory indexes before applying source-only fallbacks. + return nodeRequire.resolve(resolvedPath); + } catch (error) { + if (!hasErrnoCode(error, "MODULE_NOT_FOUND")) { + throw error; + } + } + for (const candidate of resolveSourceModuleCandidates(rootDir, specifier)) { if (fs.existsSync(candidate)) { return candidate; } } - return path.resolve(rootDir, specifier); + return resolvedPath; } /** diff --git a/src/channels/plugins/package-state-probes.test.ts b/src/channels/plugins/package-state-probes.test.ts index 94dcbc65c5cb..a34f83147289 100644 --- a/src/channels/plugins/package-state-probes.test.ts +++ b/src/channels/plugins/package-state-probes.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginChannelCatalogEntry } from "../../plugins/channel-catalog-registry.js"; import { + collectBundledChannelPackageStateLoadFailures, hasBundledChannelPackageState, listBundledChannelIdsForPackageState, } from "./package-state-probes.js"; @@ -343,6 +344,13 @@ describe("channel package-state probes", () => { expect(warning).toContain("failed to load persistedAuthState checker for matrix"); expect(warning).toContain(`plugin module path not found: ${builtRoot}`); expect(warning).not.toContain("escapes plugin root"); + expect(collectBundledChannelPackageStateLoadFailures()).toEqual([ + { + detail: expect.stringContaining(`plugin module path not found: ${builtRoot}`), + metadataKey: "persistedAuthState", + pluginId: "matrix", + }, + ]); }); it("tries dist-runtime package-state probes before falling back to source", () => { diff --git a/src/channels/plugins/package-state-probes.ts b/src/channels/plugins/package-state-probes.ts index 1fa422408fc0..0ea79334e7dc 100644 --- a/src/channels/plugins/package-state-probes.ts +++ b/src/channels/plugins/package-state-probes.ts @@ -39,7 +39,14 @@ type ChannelPackageStateMetadata = { /** * Metadata keys that can declare a lightweight package-state checker. */ -type ChannelPackageStateMetadataKey = "configuredState" | "persistedAuthState"; +const CHANNEL_PACKAGE_STATE_METADATA_KEYS = ["configuredState", "persistedAuthState"] as const; +type ChannelPackageStateMetadataKey = (typeof CHANNEL_PACKAGE_STATE_METADATA_KEYS)[number]; + +type ChannelPackageStateLoadFailure = { + detail: string; + metadataKey: ChannelPackageStateMetadataKey; + pluginId: string; +}; const log = createSubsystemLogger("channels"); const sourcePackageStateLoaderCache: PluginModuleLoaderCache = new Map(); @@ -193,7 +200,9 @@ function listChannelPackageStateCatalog( function resolveChannelPackageStateChecker(params: { entry: PluginChannelCatalogEntry; + emitWarning?: boolean; metadataKey: ChannelPackageStateMetadataKey; + onLoadError?: (detail: string) => void; }): ChannelPackageStateChecker | null { const metadata = resolveChannelPackageStateMetadata(params.entry, params.metadataKey); if (!metadata) { @@ -235,9 +244,12 @@ function resolveChannelPackageStateChecker(params: { if (loadError) { const detail = formatErrorMessage(loadError); - log.warn( - `[channels] failed to load ${params.metadataKey} checker for ${params.entry.pluginId}: ${detail}`, - ); + if (params.emitWarning !== false) { + log.warn( + `[channels] failed to load ${params.metadataKey} checker for ${params.entry.pluginId}: ${detail}`, + ); + } + params.onLoadError?.(detail); } return null; } @@ -259,6 +271,24 @@ export function listBundledChannelIdsForPackageState( .toSorted((left, right) => left.localeCompare(right)); } +/** Reports declared bundled channel package-state modules that cannot load. */ +export function collectBundledChannelPackageStateLoadFailures( + discovery?: PluginDiscoveryResult, +): ChannelPackageStateLoadFailure[] { + const failures: ChannelPackageStateLoadFailure[] = []; + for (const metadataKey of CHANNEL_PACKAGE_STATE_METADATA_KEYS) { + for (const entry of listChannelPackageStateCatalog(metadataKey, discovery)) { + resolveChannelPackageStateChecker({ + entry, + emitWarning: false, + metadataKey, + onLoadError: (detail) => failures.push({ detail, metadataKey, pluginId: entry.pluginId }), + }); + } + } + return failures; +} + /** * Returns whether a bundled channel reports configured/auth package state. */ diff --git a/src/flows/doctor-health-contributions-final.ts b/src/flows/doctor-health-contributions-final.ts index a3b29b7ef033..0bb76ffb9b94 100644 --- a/src/flows/doctor-health-contributions-final.ts +++ b/src/flows/doctor-health-contributions-final.ts @@ -41,6 +41,9 @@ import type { import { createDoctorHealthContribution } from "./doctor-health-contribution.js"; import type { HealthCheck } from "./health-checks.js"; +const CHANNEL_PACKAGE_STATE_CAPABILITIES_CHECK_ID = + "core/doctor/channel-package-state-capabilities"; + export function resolveFinalDoctorHealthContributions(params: { runSystemdLingerHealth: (ctx: DoctorHealthFlowContext) => Promise; detectSystemdLingerFindings: HealthCheck["detect"]; @@ -93,6 +96,27 @@ export function resolveFinalDoctorHealthContributions(params: { }, }, }), + createDoctorHealthContribution({ + id: "doctor:channel-package-state-capabilities", + label: "Channel package-state capabilities", + healthChecks: { + id: CHANNEL_PACKAGE_STATE_CAPABILITIES_CHECK_ID, + description: "Declared channel package-state checker modules must load.", + defaultEnabled: true, + async detect() { + const { collectBundledChannelPackageStateLoadFailures } = + await import("../channels/plugins/package-state-probes.js"); + return collectBundledChannelPackageStateLoadFailures().map((failure) => ({ + checkId: CHANNEL_PACKAGE_STATE_CAPABILITIES_CHECK_ID, + severity: "warning" as const, + message: `Plugin ${failure.pluginId} declared ${failure.metadataKey}, but its checker failed to load: ${failure.detail}`, + target: failure.pluginId, + requirement: "declared-channel-package-state-capability-loadable", + fixHint: `Rebuild or reinstall plugin ${failure.pluginId}, then rerun \`openclaw doctor\`.`, + })); + }, + }, + }), createDoctorHealthContribution({ id: "doctor:startup-channel-maintenance", label: "Startup channel maintenance", diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index b241048f6c87..6dd2f398ce3f 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -172,6 +172,7 @@ const mocks = vi.hoisted(() => ({ requirement: hit.reason, }), ), + collectBundledChannelPackageStateLoadFailures: vi.fn(() => [] as unknown[]), collectStalePluginRuntimeSymlinkHealthFindings: vi.fn(async () => [] as unknown[]), collectChannelPreviewWarningHealthFindings: vi.fn( async (): Promise => [], @@ -514,6 +515,12 @@ vi.mock("../commands/doctor/shared/channel-plugin-blockers.js", () => ({ channelPluginBlockerHitToHealthFinding: mocks.channelPluginBlockerHitToHealthFinding, })); +vi.mock("../channels/plugins/package-state-probes.js", async (importOriginal) => ({ + ...(await importOriginal()), + collectBundledChannelPackageStateLoadFailures: + mocks.collectBundledChannelPackageStateLoadFailures, +})); + vi.mock("./doctor-startup-channel-maintenance.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -805,6 +812,8 @@ describe("doctor health contributions", () => { mocks.scanConfiguredChannelPluginBlockers.mockReset(); mocks.scanConfiguredChannelPluginBlockers.mockReturnValue([]); mocks.channelPluginBlockerHitToHealthFinding.mockClear(); + mocks.collectBundledChannelPackageStateLoadFailures.mockReset(); + mocks.collectBundledChannelPackageStateLoadFailures.mockReturnValue([]); mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockReset(); mocks.collectStalePluginRuntimeSymlinkHealthFindings.mockResolvedValue([]); mocks.collectChannelPreviewWarningHealthFindings.mockReset(); @@ -2341,6 +2350,7 @@ describe("doctor health contributions", () => { expect(contributionIds).toContain("core/doctor/whatsapp-responsiveness"); expect(contributionIds).toContain("core/doctor/device-pairing"); expect(contributionIds).toContain("core/doctor/channel-plugin-blockers"); + expect(contributionIds).toContain("core/doctor/channel-package-state-capabilities"); expect(contributionIds).toContain("core/doctor/channel-preview-warnings"); expect(contributionIds).toContain("core/doctor/systemd-linger"); expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds); @@ -3058,6 +3068,41 @@ describe("doctor health contributions", () => { expect(mocks.scanConfiguredChannelPluginBlockers).toHaveBeenCalledWith(ctx.cfg, process.env); }); + it("reports channel package-state capability load failures by default", async () => { + const contributionChecks = await resolveDoctorContributionHealthChecks(); + const capabilityCheck = contributionChecks.find( + (check) => check.id === "core/doctor/channel-package-state-capabilities", + ); + expect(capabilityCheck).toMatchObject({ defaultEnabled: true }); + expect(capabilityCheck).toBeDefined(); + mocks.collectBundledChannelPackageStateLoadFailures.mockReturnValue([ + { + detail: "plugin module path not found: /plugins/example-chat/auth-presence", + metadataKey: "persistedAuthState", + pluginId: "example-chat", + }, + ]); + + const ctx = { + cfg: {}, + mode: "lint", + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + } as const; + + await expect(runDoctorLintChecks(ctx, { checks: [capabilityCheck!] })).resolves.toMatchObject({ + checksRun: 1, + checksSkipped: 0, + findings: [ + expect.objectContaining({ + checkId: "core/doctor/channel-package-state-capabilities", + severity: "warning", + target: "example-chat", + requirement: "declared-channel-package-state-capability-loadable", + }), + ], + }); + }); + it("keeps channel preview warnings opt-in for default lint selection", async () => { const contribution = requireDoctorContribution("doctor:startup-channel-maintenance"); expect(contribution.healthCheckIds).toEqual([