Files
openclaw/src/plugins/install-paths.ts
2026-04-26 11:11:58 +01:00

95 lines
2.9 KiB
TypeScript

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