fix(plugins): resolve package module specifiers (#124600)

This commit is contained in:
Peter Steinberger
2026-08-16 06:43:38 -07:00
committed by GitHub
parent 605dece171
commit 2ceb18118c
6 changed files with 146 additions and 19 deletions
@@ -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);
+18 -15
View File
@@ -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;
}
/**
@@ -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", () => {
+34 -4
View File
@@ -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.
*/
@@ -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<void>;
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",
@@ -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<readonly HealthFinding[]> => [],
@@ -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<typeof import("../channels/plugins/package-state-probes.js")>()),
collectBundledChannelPackageStateLoadFailures:
mocks.collectBundledChannelPackageStateLoadFailures,
}));
vi.mock("./doctor-startup-channel-maintenance.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./doctor-startup-channel-maintenance.js")>();
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([