mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
refactor: share plugin package entry resolution
This commit is contained in:
@@ -499,6 +499,35 @@ describe("discoverOpenClawPlugins", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects package runtimeExtensions that do not match extension entries", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "runtime-mismatch-pack");
|
||||
mkdirSafe(path.join(pluginDir, "src"));
|
||||
mkdirSafe(path.join(pluginDir, "dist"));
|
||||
|
||||
writePluginPackageManifest({
|
||||
packageDir: pluginDir,
|
||||
packageName: "@openclaw/runtime-mismatch-pack",
|
||||
extensions: ["./src/one.ts", "./src/two.ts"],
|
||||
runtimeExtensions: ["./dist/one.js"],
|
||||
});
|
||||
writePluginEntry(path.join(pluginDir, "src", "one.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "src", "two.ts"));
|
||||
writePluginEntry(path.join(pluginDir, "dist", "one.js"));
|
||||
|
||||
const result = await discoverWithStateDir(stateDir, {});
|
||||
|
||||
expectCandidatePresence(result, { absent: ["runtime-mismatch-pack"] });
|
||||
expect(
|
||||
result.diagnostics.some(
|
||||
(entry) =>
|
||||
entry.level === "error" &&
|
||||
entry.message.includes("runtimeExtensions length (1)") &&
|
||||
entry.message.includes("extensions length (2)"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("infers built dist entries for installed TypeScript package plugins", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "extensions", "built-peer-pack");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import { resolveBoundaryPathSync } from "../infra/boundary-path.js";
|
||||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -19,7 +18,10 @@ import {
|
||||
type OpenClawPackageManifest,
|
||||
type PackageManifest,
|
||||
} from "./manifest.js";
|
||||
import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js";
|
||||
import {
|
||||
resolvePackageRuntimeExtensionSources,
|
||||
resolvePackageSetupSource,
|
||||
} from "./package-entry-resolution.js";
|
||||
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
|
||||
import type { PluginOrigin } from "./plugin-origin.types.js";
|
||||
import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js";
|
||||
@@ -555,245 +557,6 @@ function discoverBundleInRoot(params: {
|
||||
return "added";
|
||||
}
|
||||
|
||||
function resolvePackageEntrySource(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const source = path.resolve(params.packageDir, params.entryPath);
|
||||
const rejectHardlinks = params.rejectHardlinks ?? true;
|
||||
const candidates = [source];
|
||||
const openCandidate = (absolutePath: string): string | null => {
|
||||
const opened = openBoundaryFileSync({
|
||||
absolutePath,
|
||||
rootPath: params.packageDir,
|
||||
boundaryLabel: "plugin package directory",
|
||||
rejectHardlinks,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return matchBoundaryFileOpenFailure(opened, {
|
||||
path: () => null,
|
||||
io: () => {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
message: `extension entry unreadable (I/O error): ${params.entryPath}`,
|
||||
source: params.sourceLabel,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
fallback: () => {
|
||||
params.diagnostics.push({
|
||||
level: "error",
|
||||
message: `extension entry escapes package directory: ${params.entryPath}`,
|
||||
source: params.sourceLabel,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
const safeSource = opened.path;
|
||||
fs.closeSync(opened.fd);
|
||||
return safeSource;
|
||||
};
|
||||
if (!rejectHardlinks) {
|
||||
const builtCandidate = source.replace(/\.[^.]+$/u, ".js");
|
||||
if (builtCandidate !== source) {
|
||||
candidates.push(builtCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of new Set(candidates)) {
|
||||
if (!fs.existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
return openCandidate(candidate);
|
||||
}
|
||||
|
||||
return openCandidate(source);
|
||||
}
|
||||
|
||||
function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean {
|
||||
return origin === "config" || origin === "global";
|
||||
}
|
||||
|
||||
function resolveSafePackageEntry(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): { relativePath: string; existingSource?: string } | null {
|
||||
const absolutePath = path.resolve(params.packageDir, params.entryPath);
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
const existingSource = resolvePackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.entryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (!existingSource) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"),
|
||||
existingSource,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
resolveBoundaryPathSync({
|
||||
absolutePath,
|
||||
rootPath: params.packageDir,
|
||||
boundaryLabel: "plugin package directory",
|
||||
});
|
||||
} catch {
|
||||
params.diagnostics.push({
|
||||
level: "error",
|
||||
message: `extension entry escapes package directory: ${params.entryPath}`,
|
||||
source: params.sourceLabel,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") };
|
||||
}
|
||||
|
||||
function resolveExistingPackageEntrySource(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const source = path.resolve(params.packageDir, params.entryPath);
|
||||
if (!fs.existsSync(source)) {
|
||||
return null;
|
||||
}
|
||||
return resolvePackageEntrySource(params);
|
||||
}
|
||||
|
||||
function normalizePackageManifestStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
function resolvePackageRuntimeEntrySource(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
runtimeEntryPath?: string;
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const safeEntry = resolveSafePackageEntry({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.entryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (!safeEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (params.runtimeEntryPath) {
|
||||
const runtimeSource = resolvePackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.runtimeEntryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (runtimeSource) {
|
||||
return runtimeSource;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInferBuiltRuntimeEntry(params.origin)) {
|
||||
for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) {
|
||||
const runtimeSource = resolveExistingPackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: candidate,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (runtimeSource) {
|
||||
return runtimeSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (safeEntry.existingSource) {
|
||||
return safeEntry.existingSource;
|
||||
}
|
||||
|
||||
return resolvePackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.entryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePackageSetupSource(params: {
|
||||
packageDir: string;
|
||||
manifest: PackageManifest | null;
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
|
||||
const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry);
|
||||
if (!setupEntryPath) {
|
||||
return null;
|
||||
}
|
||||
return resolvePackageRuntimeEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: setupEntryPath,
|
||||
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
|
||||
origin: params.origin,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePackageRuntimeExtensionEntries(params: {
|
||||
packageDir: string;
|
||||
manifest: PackageManifest | null;
|
||||
extensions: readonly string[];
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string[] {
|
||||
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
|
||||
const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions);
|
||||
return params.extensions.flatMap((entryPath, index) => {
|
||||
const source = resolvePackageRuntimeEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath,
|
||||
runtimeEntryPath:
|
||||
runtimeExtensions.length === params.extensions.length
|
||||
? runtimeExtensions[index]
|
||||
: undefined,
|
||||
origin: params.origin,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
return source ? [source] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function discoverInDirectory(params: {
|
||||
dir: string;
|
||||
origin: PluginOrigin;
|
||||
@@ -871,7 +634,7 @@ function discoverInDirectory(params: {
|
||||
});
|
||||
|
||||
if (extensions.length > 0) {
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
|
||||
packageDir: fullPath,
|
||||
manifest,
|
||||
extensions,
|
||||
@@ -1007,7 +770,7 @@ function discoverFromPath(params: {
|
||||
});
|
||||
|
||||
if (extensions.length > 0) {
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({
|
||||
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
|
||||
packageDir: resolved,
|
||||
manifest,
|
||||
extensions,
|
||||
|
||||
94
src/plugins/install-paths.ts
Normal file
94
src/plugins/install-paths.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
resolveSafeInstallDir,
|
||||
safeDirName,
|
||||
safePathSegmentHashed,
|
||||
unscopedPackageName,
|
||||
} from "../infra/install-safe-path.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
|
||||
export function safePluginInstallFileName(input: string): string {
|
||||
return safeDirName(input);
|
||||
}
|
||||
|
||||
export function encodePluginInstallDirName(pluginId: string): string {
|
||||
const trimmed = pluginId.trim();
|
||||
if (!trimmed.includes("/")) {
|
||||
return safeDirName(trimmed);
|
||||
}
|
||||
// Scoped plugin ids need a reserved on-disk namespace so they cannot collide
|
||||
// with valid unscoped ids that happen to match the hashed slug.
|
||||
return `@${safePathSegmentHashed(trimmed)}`;
|
||||
}
|
||||
|
||||
export function validatePluginId(pluginId: string): string | null {
|
||||
const trimmed = pluginId.trim();
|
||||
if (!trimmed) {
|
||||
return "invalid plugin name: missing";
|
||||
}
|
||||
if (trimmed.includes("\\")) {
|
||||
return "invalid plugin name: path separators not allowed";
|
||||
}
|
||||
const segments = trimmed.split("/");
|
||||
if (segments.some((segment) => !segment)) {
|
||||
return "invalid plugin name: malformed scope";
|
||||
}
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||
return "invalid plugin name: reserved path segment";
|
||||
}
|
||||
if (segments.length === 1) {
|
||||
if (trimmed.startsWith("@")) {
|
||||
return "invalid plugin name: scoped ids must use @scope/name format";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (segments.length !== 2) {
|
||||
return "invalid plugin name: path separators not allowed";
|
||||
}
|
||||
if (!segments[0]?.startsWith("@") || segments[0].length < 2) {
|
||||
return "invalid plugin name: scoped ids must use @scope/name format";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function matchesExpectedPluginId(params: {
|
||||
expectedPluginId?: string;
|
||||
pluginId: string;
|
||||
manifestPluginId?: string;
|
||||
npmPluginId: string;
|
||||
}): boolean {
|
||||
if (!params.expectedPluginId) {
|
||||
return true;
|
||||
}
|
||||
if (params.expectedPluginId === params.pluginId) {
|
||||
return true;
|
||||
}
|
||||
// Backward compatibility: older install records keyed scoped npm packages by
|
||||
// their unscoped package name. Preserve update-in-place for those records
|
||||
// unless the package declares an explicit manifest id override.
|
||||
return (
|
||||
!params.manifestPluginId &&
|
||||
params.pluginId === params.npmPluginId &&
|
||||
params.expectedPluginId === unscopedPackageName(params.npmPluginId)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
|
||||
const extensionsBase = extensionsDir
|
||||
? resolveUserPath(extensionsDir)
|
||||
: path.join(CONFIG_DIR, "extensions");
|
||||
const pluginIdError = validatePluginId(pluginId);
|
||||
if (pluginIdError) {
|
||||
throw new Error(pluginIdError);
|
||||
}
|
||||
const targetDirResult = resolveSafeInstallDir({
|
||||
baseDir: extensionsBase,
|
||||
id: pluginId,
|
||||
invalidNameMessage: "invalid plugin name: path traversal detected",
|
||||
nameEncoder: encodePluginInstallDirName,
|
||||
});
|
||||
if (!targetDirResult.ok) {
|
||||
throw new Error(targetDirResult.error);
|
||||
}
|
||||
return targetDirResult.path;
|
||||
}
|
||||
@@ -865,6 +865,107 @@ describe("installPluginFromArchive", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects package installs when runtimeExtensions length does not match extensions", async () => {
|
||||
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
||||
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "runtime-mismatch-plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: {
|
||||
extensions: ["./src/one.ts", "./src/two.ts"],
|
||||
runtimeExtensions: ["./dist/one.js"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "dist", "one.js"), "export {};\n");
|
||||
|
||||
const result = await installPluginFromDir({
|
||||
dirPath: pluginDir,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
|
||||
expect(result.error).toContain("runtimeExtensions length (1)");
|
||||
expect(result.error).toContain("extensions length (2)");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects package installs when an extension entry is a symlink escape", async () => {
|
||||
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
||||
const outsideDir = path.join(path.dirname(pluginDir), "outside-symlink");
|
||||
const outsideEntry = path.join(outsideDir, "escape.js");
|
||||
const linkedDir = path.join(pluginDir, "linked");
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.writeFileSync(outsideEntry, "export {};\n");
|
||||
try {
|
||||
fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "symlink-entry-plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./linked/escape.js"] },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await installPluginFromDir({
|
||||
dirPath: pluginDir,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
|
||||
expect(result.error).toContain("extension entry");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects package installs when an extension entry is a hardlinked alias", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
||||
const outsideDir = path.join(path.dirname(pluginDir), "outside-hardlink");
|
||||
const outsideEntry = path.join(outsideDir, "escape.js");
|
||||
const linkedEntry = path.join(pluginDir, "escape.js");
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.writeFileSync(outsideEntry, "export {};\n");
|
||||
try {
|
||||
fs.linkSync(outsideEntry, linkedEntry);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "hardlink-entry-plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./escape.js"] },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await installPluginFromDir({
|
||||
dirPath: pluginDir,
|
||||
extensionsDir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
|
||||
expect(result.error).toContain("boundary checks");
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks package installs when plugin contains dangerous code patterns", async () => {
|
||||
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
|
||||
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { matchBoundaryFileOpenFailure, openBoundaryFile } from "../infra/boundary-file-read.js";
|
||||
import { resolveBoundaryPath } from "../infra/boundary-path.js";
|
||||
import {
|
||||
packageNameMatchesId,
|
||||
resolveSafeInstallDir,
|
||||
safeDirName,
|
||||
safePathSegmentHashed,
|
||||
unscopedPackageName,
|
||||
} from "../infra/install-safe-path.js";
|
||||
import { packageNameMatchesId } from "../infra/install-safe-path.js";
|
||||
import { type NpmIntegrityDrift, type NpmSpecResolution } from "../infra/install-source-utils.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
encodePluginInstallDirName,
|
||||
matchesExpectedPluginId,
|
||||
safePluginInstallFileName,
|
||||
validatePluginId,
|
||||
} from "./install-paths.js";
|
||||
import type { InstallSecurityScanResult } from "./install-security-scan.js";
|
||||
import type { InstallSafetyOverrides } from "./install-security-scan.js";
|
||||
import {
|
||||
getPackageManifestMetadata,
|
||||
resolvePackageExtensionEntries,
|
||||
type PackageManifest as PluginPackageManifest,
|
||||
} from "./manifest.js";
|
||||
import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js";
|
||||
import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js";
|
||||
|
||||
export { resolvePluginInstallDir } from "./install-paths.js";
|
||||
|
||||
let pluginInstallRuntimePromise: Promise<typeof import("./install.runtime.js")> | undefined;
|
||||
|
||||
@@ -95,71 +93,6 @@ type PluginInstallPolicyRequest = {
|
||||
};
|
||||
|
||||
const defaultLogger: PluginInstallLogger = {};
|
||||
function safeFileName(input: string): string {
|
||||
return safeDirName(input);
|
||||
}
|
||||
|
||||
function encodePluginInstallDirName(pluginId: string): string {
|
||||
const trimmed = pluginId.trim();
|
||||
if (!trimmed.includes("/")) {
|
||||
return safeDirName(trimmed);
|
||||
}
|
||||
// Scoped plugin ids need a reserved on-disk namespace so they cannot collide
|
||||
// with valid unscoped ids that happen to match the hashed slug.
|
||||
return `@${safePathSegmentHashed(trimmed)}`;
|
||||
}
|
||||
|
||||
function validatePluginId(pluginId: string): string | null {
|
||||
const trimmed = pluginId.trim();
|
||||
if (!trimmed) {
|
||||
return "invalid plugin name: missing";
|
||||
}
|
||||
if (trimmed.includes("\\")) {
|
||||
return "invalid plugin name: path separators not allowed";
|
||||
}
|
||||
const segments = trimmed.split("/");
|
||||
if (segments.some((segment) => !segment)) {
|
||||
return "invalid plugin name: malformed scope";
|
||||
}
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||
return "invalid plugin name: reserved path segment";
|
||||
}
|
||||
if (segments.length === 1) {
|
||||
if (trimmed.startsWith("@")) {
|
||||
return "invalid plugin name: scoped ids must use @scope/name format";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (segments.length !== 2) {
|
||||
return "invalid plugin name: path separators not allowed";
|
||||
}
|
||||
if (!segments[0]?.startsWith("@") || segments[0].length < 2) {
|
||||
return "invalid plugin name: scoped ids must use @scope/name format";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesExpectedPluginId(params: {
|
||||
expectedPluginId?: string;
|
||||
pluginId: string;
|
||||
manifestPluginId?: string;
|
||||
npmPluginId: string;
|
||||
}): boolean {
|
||||
if (!params.expectedPluginId) {
|
||||
return true;
|
||||
}
|
||||
if (params.expectedPluginId === params.pluginId) {
|
||||
return true;
|
||||
}
|
||||
// Backward compatibility: older install records keyed scoped npm packages by
|
||||
// their unscoped package name. Preserve update-in-place for those records
|
||||
// unless the package declares an explicit manifest id override.
|
||||
return (
|
||||
!params.manifestPluginId &&
|
||||
params.pluginId === params.npmPluginId &&
|
||||
params.expectedPluginId === unscopedPackageName(params.npmPluginId)
|
||||
);
|
||||
}
|
||||
|
||||
function ensureOpenClawExtensions(params: { manifest: PackageManifest }):
|
||||
| {
|
||||
@@ -192,139 +125,6 @@ function ensureOpenClawExtensions(params: { manifest: PackageManifest }):
|
||||
};
|
||||
}
|
||||
|
||||
type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string };
|
||||
|
||||
async function validatePackageExtensionEntry(params: {
|
||||
packageDir: string;
|
||||
entry: string;
|
||||
label: string;
|
||||
requireExisting: boolean;
|
||||
}): Promise<ExtensionEntryValidation> {
|
||||
const absolutePath = path.resolve(params.packageDir, params.entry);
|
||||
try {
|
||||
const resolved = await resolveBoundaryPath({
|
||||
absolutePath,
|
||||
rootPath: params.packageDir,
|
||||
boundaryLabel: "plugin package directory",
|
||||
});
|
||||
if (!resolved.exists) {
|
||||
return params.requireExisting
|
||||
? { ok: false, error: `${params.label} not found: ${params.entry}` }
|
||||
: { ok: true, exists: false };
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${params.label} escapes plugin directory: ${params.entry}`,
|
||||
};
|
||||
}
|
||||
|
||||
const opened = await openBoundaryFile({
|
||||
absolutePath,
|
||||
rootPath: params.packageDir,
|
||||
boundaryLabel: "plugin package directory",
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return matchBoundaryFileOpenFailure(opened, {
|
||||
path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }),
|
||||
io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }),
|
||||
validation: () => ({
|
||||
ok: false,
|
||||
error: `${params.label} failed plugin directory boundary checks: ${params.entry}`,
|
||||
}),
|
||||
fallback: () => ({
|
||||
ok: false,
|
||||
error: `${params.label} failed plugin directory boundary checks: ${params.entry}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
fsSync.closeSync(opened.fd);
|
||||
return { ok: true, exists: true };
|
||||
}
|
||||
|
||||
async function validatePackageExtensionEntries(params: {
|
||||
packageDir: string;
|
||||
extensions: string[];
|
||||
manifest: PackageManifest;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string; code: PluginInstallErrorCode }> {
|
||||
const packageMetadata = getPackageManifestMetadata(params.manifest);
|
||||
const runtimeExtensions = Array.isArray(packageMetadata?.runtimeExtensions)
|
||||
? packageMetadata.runtimeExtensions
|
||||
.map((entry) => normalizeOptionalString(entry) ?? "")
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const useRuntimeExtensions = runtimeExtensions.length === params.extensions.length;
|
||||
|
||||
for (const [index, entry] of params.extensions.entries()) {
|
||||
const sourceEntry = await validatePackageExtensionEntry({
|
||||
packageDir: params.packageDir,
|
||||
entry,
|
||||
label: "extension entry",
|
||||
requireExisting: false,
|
||||
});
|
||||
if (!sourceEntry.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: sourceEntry.error,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeEntry = useRuntimeExtensions ? runtimeExtensions[index] : undefined;
|
||||
if (runtimeEntry) {
|
||||
const runtimeResult = await validatePackageExtensionEntry({
|
||||
packageDir: params.packageDir,
|
||||
entry: runtimeEntry,
|
||||
label: "runtime extension entry",
|
||||
requireExisting: true,
|
||||
});
|
||||
if (!runtimeResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: runtimeResult.error,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceEntry.exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let foundBuiltEntry = false;
|
||||
for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) {
|
||||
const builtResult = await validatePackageExtensionEntry({
|
||||
packageDir: params.packageDir,
|
||||
entry: builtEntry,
|
||||
label: "inferred runtime extension entry",
|
||||
requireExisting: false,
|
||||
});
|
||||
if (!builtResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: builtResult.error,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
|
||||
};
|
||||
}
|
||||
if (builtResult.exists) {
|
||||
foundBuiltEntry = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundBuiltEntry) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `extension entry not found: ${entry}`,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function isNpmPackageNotFoundMessage(error: string): boolean {
|
||||
const normalized = error.trim();
|
||||
if (normalized.startsWith("Package not found on npm:")) {
|
||||
@@ -581,26 +381,6 @@ async function installPluginDirectoryIntoExtensions(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
|
||||
const extensionsBase = extensionsDir
|
||||
? resolveUserPath(extensionsDir)
|
||||
: path.join(CONFIG_DIR, "extensions");
|
||||
const pluginIdError = validatePluginId(pluginId);
|
||||
if (pluginIdError) {
|
||||
throw new Error(pluginIdError);
|
||||
}
|
||||
const targetDirResult = resolveSafeInstallDir({
|
||||
baseDir: extensionsBase,
|
||||
id: pluginId,
|
||||
invalidNameMessage: "invalid plugin name: path traversal detected",
|
||||
nameEncoder: encodePluginInstallDirName,
|
||||
});
|
||||
if (!targetDirResult.ok) {
|
||||
throw new Error(targetDirResult.error);
|
||||
}
|
||||
return targetDirResult.path;
|
||||
}
|
||||
|
||||
async function resolvePluginInstallTarget(params: {
|
||||
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
||||
pluginId: string;
|
||||
@@ -905,13 +685,17 @@ async function installPluginFromPackageDir(
|
||||
};
|
||||
}
|
||||
|
||||
const extensionValidation = await validatePackageExtensionEntries({
|
||||
const extensionValidation = await validatePackageExtensionEntriesForInstall({
|
||||
packageDir: params.packageDir,
|
||||
extensions,
|
||||
manifest,
|
||||
});
|
||||
if (!extensionValidation.ok) {
|
||||
return extensionValidation;
|
||||
return {
|
||||
ok: false,
|
||||
error: extensionValidation.error,
|
||||
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
|
||||
};
|
||||
}
|
||||
|
||||
const targetResult = await resolvePreparedDirectoryInstallTarget({
|
||||
@@ -1099,7 +883,10 @@ export async function installPluginFromFile(params: {
|
||||
if (pluginIdError) {
|
||||
return { ok: false, error: pluginIdError };
|
||||
}
|
||||
const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`);
|
||||
const targetFile = path.join(
|
||||
extensionsDir,
|
||||
`${safePluginInstallFileName(pluginId)}${path.extname(filePath)}`,
|
||||
);
|
||||
const preparedTarget: PreparedInstallTarget = {
|
||||
targetPath: targetFile,
|
||||
effectiveMode: await resolveEffectiveInstallMode({
|
||||
|
||||
412
src/plugins/package-entry-resolution.ts
Normal file
412
src/plugins/package-entry-resolution.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
matchBoundaryFileOpenFailure,
|
||||
openBoundaryFile,
|
||||
openBoundaryFileSync,
|
||||
} from "../infra/boundary-file-read.js";
|
||||
import { resolveBoundaryPath, resolveBoundaryPathSync } from "../infra/boundary-path.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js";
|
||||
import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js";
|
||||
import type { PluginOrigin } from "./plugin-origin.types.js";
|
||||
|
||||
type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string };
|
||||
|
||||
type RuntimeExtensionsResolution =
|
||||
| { ok: true; runtimeExtensions: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function runtimeExtensionsLengthMismatchMessage(params: {
|
||||
runtimeExtensionsLength: number;
|
||||
extensionsLength: number;
|
||||
}): string {
|
||||
return (
|
||||
`package.json openclaw.runtimeExtensions length (${params.runtimeExtensionsLength}) ` +
|
||||
`must match openclaw.extensions length (${params.extensionsLength})`
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePackageManifestStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
export function resolvePackageRuntimeExtensionEntries(params: {
|
||||
manifest: PackageManifest | null | undefined;
|
||||
extensions: readonly string[];
|
||||
}): RuntimeExtensionsResolution {
|
||||
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
|
||||
const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions);
|
||||
if (runtimeExtensions.length === 0) {
|
||||
return { ok: true, runtimeExtensions: [] };
|
||||
}
|
||||
if (runtimeExtensions.length !== params.extensions.length) {
|
||||
return {
|
||||
ok: false,
|
||||
error: runtimeExtensionsLengthMismatchMessage({
|
||||
runtimeExtensionsLength: runtimeExtensions.length,
|
||||
extensionsLength: params.extensions.length,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, runtimeExtensions };
|
||||
}
|
||||
|
||||
async function validatePackageExtensionEntry(params: {
|
||||
packageDir: string;
|
||||
entry: string;
|
||||
label: string;
|
||||
requireExisting: boolean;
|
||||
}): Promise<ExtensionEntryValidation> {
|
||||
const absolutePath = path.resolve(params.packageDir, params.entry);
|
||||
try {
|
||||
const resolved = await resolveBoundaryPath({
|
||||
absolutePath,
|
||||
rootPath: params.packageDir,
|
||||
boundaryLabel: "plugin package directory",
|
||||
});
|
||||
if (!resolved.exists) {
|
||||
return params.requireExisting
|
||||
? { ok: false, error: `${params.label} not found: ${params.entry}` }
|
||||
: { ok: true, exists: false };
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${params.label} escapes plugin directory: ${params.entry}`,
|
||||
};
|
||||
}
|
||||
|
||||
const opened = await openBoundaryFile({
|
||||
absolutePath,
|
||||
rootPath: params.packageDir,
|
||||
boundaryLabel: "plugin package directory",
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return matchBoundaryFileOpenFailure(opened, {
|
||||
path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }),
|
||||
io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }),
|
||||
validation: () => ({
|
||||
ok: false,
|
||||
error: `${params.label} failed plugin directory boundary checks: ${params.entry}`,
|
||||
}),
|
||||
fallback: () => ({
|
||||
ok: false,
|
||||
error: `${params.label} failed plugin directory boundary checks: ${params.entry}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
fs.closeSync(opened.fd);
|
||||
return { ok: true, exists: true };
|
||||
}
|
||||
|
||||
export async function validatePackageExtensionEntriesForInstall(params: {
|
||||
packageDir: string;
|
||||
extensions: string[];
|
||||
manifest: PackageManifest;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const runtimeResolution = resolvePackageRuntimeExtensionEntries({
|
||||
manifest: params.manifest,
|
||||
extensions: params.extensions,
|
||||
});
|
||||
if (!runtimeResolution.ok) {
|
||||
return runtimeResolution;
|
||||
}
|
||||
|
||||
for (const [index, entry] of params.extensions.entries()) {
|
||||
const sourceEntry = await validatePackageExtensionEntry({
|
||||
packageDir: params.packageDir,
|
||||
entry,
|
||||
label: "extension entry",
|
||||
requireExisting: false,
|
||||
});
|
||||
if (!sourceEntry.ok) {
|
||||
return sourceEntry;
|
||||
}
|
||||
|
||||
const runtimeEntry = runtimeResolution.runtimeExtensions[index];
|
||||
if (runtimeEntry) {
|
||||
const runtimeResult = await validatePackageExtensionEntry({
|
||||
packageDir: params.packageDir,
|
||||
entry: runtimeEntry,
|
||||
label: "runtime extension entry",
|
||||
requireExisting: true,
|
||||
});
|
||||
if (!runtimeResult.ok) {
|
||||
return runtimeResult;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceEntry.exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let foundBuiltEntry = false;
|
||||
for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) {
|
||||
const builtResult = await validatePackageExtensionEntry({
|
||||
packageDir: params.packageDir,
|
||||
entry: builtEntry,
|
||||
label: "inferred runtime extension entry",
|
||||
requireExisting: false,
|
||||
});
|
||||
if (!builtResult.ok) {
|
||||
return builtResult;
|
||||
}
|
||||
if (builtResult.exists) {
|
||||
foundBuiltEntry = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundBuiltEntry) {
|
||||
return { ok: false, error: `extension entry not found: ${entry}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function resolvePackageEntrySource(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const source = path.resolve(params.packageDir, params.entryPath);
|
||||
const rejectHardlinks = params.rejectHardlinks ?? true;
|
||||
const candidates = [source];
|
||||
const openCandidate = (absolutePath: string): string | null => {
|
||||
const opened = openBoundaryFileSync({
|
||||
absolutePath,
|
||||
rootPath: params.packageDir,
|
||||
boundaryLabel: "plugin package directory",
|
||||
rejectHardlinks,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return matchBoundaryFileOpenFailure(opened, {
|
||||
path: () => null,
|
||||
io: () => {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
message: `extension entry unreadable (I/O error): ${params.entryPath}`,
|
||||
source: params.sourceLabel,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
fallback: () => {
|
||||
params.diagnostics.push({
|
||||
level: "error",
|
||||
message: `extension entry escapes package directory: ${params.entryPath}`,
|
||||
source: params.sourceLabel,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
const safeSource = opened.path;
|
||||
fs.closeSync(opened.fd);
|
||||
return safeSource;
|
||||
};
|
||||
if (!rejectHardlinks) {
|
||||
const builtCandidate = source.replace(/\.[^.]+$/u, ".js");
|
||||
if (builtCandidate !== source) {
|
||||
candidates.push(builtCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of new Set(candidates)) {
|
||||
if (!fs.existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
return openCandidate(candidate);
|
||||
}
|
||||
|
||||
return openCandidate(source);
|
||||
}
|
||||
|
||||
function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean {
|
||||
return origin === "config" || origin === "global";
|
||||
}
|
||||
|
||||
function resolveSafePackageEntry(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): { relativePath: string; existingSource?: string } | null {
|
||||
const absolutePath = path.resolve(params.packageDir, params.entryPath);
|
||||
if (fs.existsSync(absolutePath)) {
|
||||
const existingSource = resolvePackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.entryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (!existingSource) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"),
|
||||
existingSource,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
resolveBoundaryPathSync({
|
||||
absolutePath,
|
||||
rootPath: params.packageDir,
|
||||
boundaryLabel: "plugin package directory",
|
||||
});
|
||||
} catch {
|
||||
params.diagnostics.push({
|
||||
level: "error",
|
||||
message: `extension entry escapes package directory: ${params.entryPath}`,
|
||||
source: params.sourceLabel,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") };
|
||||
}
|
||||
|
||||
function resolveExistingPackageEntrySource(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const source = path.resolve(params.packageDir, params.entryPath);
|
||||
if (!fs.existsSync(source)) {
|
||||
return null;
|
||||
}
|
||||
return resolvePackageEntrySource(params);
|
||||
}
|
||||
|
||||
function resolvePackageRuntimeEntrySource(params: {
|
||||
packageDir: string;
|
||||
entryPath: string;
|
||||
runtimeEntryPath?: string;
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const safeEntry = resolveSafePackageEntry({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.entryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (!safeEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (params.runtimeEntryPath) {
|
||||
const runtimeSource = resolvePackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.runtimeEntryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (runtimeSource) {
|
||||
return runtimeSource;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldInferBuiltRuntimeEntry(params.origin)) {
|
||||
for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) {
|
||||
const runtimeSource = resolveExistingPackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: candidate,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
if (runtimeSource) {
|
||||
return runtimeSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (safeEntry.existingSource) {
|
||||
return safeEntry.existingSource;
|
||||
}
|
||||
|
||||
return resolvePackageEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: params.entryPath,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePackageSetupSource(params: {
|
||||
packageDir: string;
|
||||
manifest: PackageManifest | null;
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string | null {
|
||||
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
|
||||
const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry);
|
||||
if (!setupEntryPath) {
|
||||
return null;
|
||||
}
|
||||
return resolvePackageRuntimeEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath: setupEntryPath,
|
||||
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
|
||||
origin: params.origin,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePackageRuntimeExtensionSources(params: {
|
||||
packageDir: string;
|
||||
manifest: PackageManifest | null;
|
||||
extensions: readonly string[];
|
||||
origin: PluginOrigin;
|
||||
sourceLabel: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks?: boolean;
|
||||
}): string[] {
|
||||
const runtimeResolution = resolvePackageRuntimeExtensionEntries({
|
||||
manifest: params.manifest,
|
||||
extensions: params.extensions,
|
||||
});
|
||||
if (!runtimeResolution.ok) {
|
||||
params.diagnostics.push({
|
||||
level: "error",
|
||||
message: runtimeResolution.error,
|
||||
source: params.sourceLabel,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
return params.extensions.flatMap((entryPath, index) => {
|
||||
const source = resolvePackageRuntimeEntrySource({
|
||||
packageDir: params.packageDir,
|
||||
entryPath,
|
||||
runtimeEntryPath: runtimeResolution.runtimeExtensions[index],
|
||||
origin: params.origin,
|
||||
sourceLabel: params.sourceLabel,
|
||||
diagnostics: params.diagnostics,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
});
|
||||
return source ? [source] : [];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user