refactor: share plugin package entry resolution

This commit is contained in:
Peter Steinberger
2026-04-26 11:11:41 +01:00
parent 8ba9c9098a
commit f337c9019c
6 changed files with 663 additions and 477 deletions

View File

@@ -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");

View File

@@ -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,

View 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;
}

View File

@@ -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();

View File

@@ -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({

View 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] : [];
});
}