mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(plugins): resolve package module specifiers (#124600)
This commit is contained in:
committed by
GitHub
parent
605dece171
commit
2ceb18118c
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user