mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 10:31:33 +00:00
* fix(claws): report plugin setup readiness * fix(claws): keep setup readiness helper private * fix(claws): honor local provider auth evidence * fix(claws): require auth-method-only setup * fix(azure-speech): keep setup envs credential-only * revert(azure-speech): preserve compound setup metadata
481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
import path from "node:path";
|
|
import { satisfiesPluginApiRange } from "../infra/clawhub.js";
|
|
import type { InstallPolicySource } from "../security/install-policy.js";
|
|
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { resolveDefaultPluginExtensionsDir } from "./install-paths.js";
|
|
import type { InstallSecurityScanResult } from "./install-security-scan.js";
|
|
import {
|
|
PLUGIN_INSTALL_ERROR_CODE,
|
|
type InstallPluginResult,
|
|
type PackageManifest,
|
|
type PluginInstallErrorCode,
|
|
type PluginInstallFailureResult,
|
|
type PluginInstallLogger,
|
|
type PluginInstallPolicyRequest,
|
|
} from "./install-types.js";
|
|
import { resolvePackageExtensionEntries, type OpenClawPackageManifest } from "./manifest.js";
|
|
import { resolvePackagePluginApiRange } from "./package-compat.js";
|
|
import {
|
|
emitPluginAuditSecurityEvent,
|
|
emitPluginInstallSecurityEvent,
|
|
pluginAuditOutcomeForReason,
|
|
type PluginSecuritySourceFamily,
|
|
} from "./security-events.js";
|
|
|
|
const pluginInstallRuntimeLoader = createLazyImportLoader(() => import("./install.runtime.js"));
|
|
|
|
export async function loadPluginInstallRuntime() {
|
|
return await pluginInstallRuntimeLoader.load();
|
|
}
|
|
|
|
export type PluginInstallRuntime = Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
|
|
|
export const defaultLogger: PluginInstallLogger = {};
|
|
|
|
export function formatUnresolvedOpenClawPeerLinkError(packageName: string): string {
|
|
return `Installed plugin ${packageName} declares openclaw as a peer dependency, but OpenClaw could not create a plugin-local node_modules/openclaw link. Run from a packaged OpenClaw install or reinstall OpenClaw, then retry.`;
|
|
}
|
|
|
|
const MISSING_EXTENSIONS_ERROR =
|
|
'package.json missing openclaw.extensions; update the plugin package to include openclaw.extensions (for example ["./dist/index.js"]). See https://docs.openclaw.ai/help/troubleshooting#plugin-install-fails-with-missing-openclaw-extensions';
|
|
function validateOpenClawPackageCompatibility(params: {
|
|
pluginId: string;
|
|
currentHostVersion: string;
|
|
packageMetadata?: OpenClawPackageManifest;
|
|
}): PluginInstallFailureResult | null {
|
|
const pluginApiRangeCheck = resolvePackagePluginApiRange(params.packageMetadata);
|
|
if (!pluginApiRangeCheck.ok) {
|
|
return {
|
|
ok: false,
|
|
error: `invalid package.json openclaw.compat.pluginApi: ${pluginApiRangeCheck.error}`,
|
|
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_PLUGIN_API,
|
|
};
|
|
}
|
|
const pluginApiRange = pluginApiRangeCheck.range;
|
|
if (pluginApiRange && !satisfiesPluginApiRange(params.currentHostVersion, pluginApiRange)) {
|
|
return {
|
|
ok: false,
|
|
error: `plugin "${params.pluginId}" requires plugin API ${pluginApiRange}, but this OpenClaw runtime exposes ${params.currentHostVersion}. Upgrade OpenClaw or install a compatible plugin version and retry.`,
|
|
code: PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function validateOpenClawPackageInstallCompatibility(params: {
|
|
runtime: PluginInstallRuntime;
|
|
pluginId: string;
|
|
packageMetadata?: OpenClawPackageManifest;
|
|
}): PluginInstallFailureResult | null {
|
|
const currentHostVersion = params.runtime.resolveCompatibilityHostVersion();
|
|
const minHostVersionCheck = params.runtime.checkMinHostVersion({
|
|
currentVersion: currentHostVersion,
|
|
minHostVersion: params.packageMetadata?.install?.minHostVersion,
|
|
});
|
|
if (!minHostVersionCheck.ok) {
|
|
if (minHostVersionCheck.kind === "invalid") {
|
|
return {
|
|
ok: false,
|
|
error: `invalid package.json openclaw.install.minHostVersion: ${minHostVersionCheck.error}`,
|
|
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_MIN_HOST_VERSION,
|
|
};
|
|
}
|
|
if (minHostVersionCheck.kind === "unknown_host_version") {
|
|
return {
|
|
ok: false,
|
|
error: `plugin "${params.pluginId}" requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host version could not be determined. Re-run from a released build or set OPENCLAW_VERSION and retry.`,
|
|
code: PLUGIN_INSTALL_ERROR_CODE.UNKNOWN_HOST_VERSION,
|
|
};
|
|
}
|
|
return {
|
|
ok: false,
|
|
error: `plugin "${params.pluginId}" requires OpenClaw >=${minHostVersionCheck.requirement.minimumLabel}, but this host is ${minHostVersionCheck.currentVersion}. Upgrade OpenClaw and retry.`,
|
|
code: PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_HOST_VERSION,
|
|
};
|
|
}
|
|
|
|
return validateOpenClawPackageCompatibility({
|
|
pluginId: params.pluginId,
|
|
currentHostVersion,
|
|
packageMetadata: params.packageMetadata,
|
|
});
|
|
}
|
|
|
|
export async function readOptionalPackageManifest(params: {
|
|
runtime: PluginInstallRuntime;
|
|
packageDir: string;
|
|
}): Promise<{ ok: true; manifest?: PackageManifest } | PluginInstallFailureResult> {
|
|
const manifestPath = path.join(params.packageDir, "package.json");
|
|
if (!(await params.runtime.fileExists(manifestPath))) {
|
|
return { ok: true };
|
|
}
|
|
|
|
try {
|
|
return {
|
|
ok: true,
|
|
manifest: await params.runtime.readJsonFile<PackageManifest>(manifestPath),
|
|
};
|
|
} catch (err) {
|
|
return { ok: false, error: `invalid package.json: ${String(err)}` };
|
|
}
|
|
}
|
|
|
|
export function ensureOpenClawExtensions(params: { manifest: PackageManifest }):
|
|
| {
|
|
ok: true;
|
|
entries: string[];
|
|
}
|
|
| {
|
|
ok: false;
|
|
error: string;
|
|
code: PluginInstallErrorCode;
|
|
} {
|
|
const resolved = resolvePackageExtensionEntries(params.manifest);
|
|
if (resolved.status === "missing") {
|
|
return {
|
|
ok: false,
|
|
error: MISSING_EXTENSIONS_ERROR,
|
|
code: PLUGIN_INSTALL_ERROR_CODE.MISSING_OPENCLAW_EXTENSIONS,
|
|
};
|
|
}
|
|
if (resolved.status === "empty") {
|
|
return {
|
|
ok: false,
|
|
error: "package.json openclaw.extensions is empty",
|
|
code: PLUGIN_INSTALL_ERROR_CODE.EMPTY_OPENCLAW_EXTENSIONS,
|
|
};
|
|
}
|
|
if (resolved.status === "invalid") {
|
|
return {
|
|
ok: false,
|
|
error: resolved.error,
|
|
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
entries: resolved.entries,
|
|
};
|
|
}
|
|
|
|
export function buildDirectoryInstallResult(params: {
|
|
pluginId: string;
|
|
targetDir: string;
|
|
manifestName?: string;
|
|
version?: string;
|
|
extensions: string[];
|
|
setup?: import("./manifest.js").PluginManifestSetup;
|
|
}): InstallPluginResult {
|
|
return {
|
|
ok: true,
|
|
pluginId: params.pluginId,
|
|
targetDir: params.targetDir,
|
|
manifestName: params.manifestName,
|
|
version: params.version,
|
|
extensions: params.extensions,
|
|
...(params.setup ? { setup: params.setup } : {}),
|
|
};
|
|
}
|
|
|
|
export function emitSuccessfulPluginInstallSecurityEvent(
|
|
result: InstallPluginResult,
|
|
params: {
|
|
dryRun?: boolean;
|
|
mode: "install" | "update";
|
|
sourceFamily: PluginSecuritySourceFamily;
|
|
trustedSourceLinkedOfficialInstall?: boolean;
|
|
},
|
|
) {
|
|
if (params.dryRun || !result.ok) {
|
|
return;
|
|
}
|
|
emitPluginInstallSecurityEvent({
|
|
pluginId: result.pluginId,
|
|
mode: params.mode,
|
|
sourceFamily: params.sourceFamily,
|
|
extensionCount: result.extensions.length,
|
|
hasVersion: Boolean(result.version),
|
|
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
|
});
|
|
}
|
|
|
|
export function hasPackageRuntimeDependencies(manifest: PackageManifest): boolean {
|
|
return (
|
|
Object.keys(manifest.dependencies ?? {}).length > 0 ||
|
|
Object.keys(manifest.optionalDependencies ?? {}).length > 0
|
|
);
|
|
}
|
|
|
|
function buildBlockedInstallResult(params: {
|
|
blocked: NonNullable<NonNullable<InstallSecurityScanResult>["blocked"]>;
|
|
}): Extract<InstallPluginResult, { ok: false }> {
|
|
return {
|
|
ok: false,
|
|
error: params.blocked.reason,
|
|
...(params.blocked.code === "security_scan_failed"
|
|
? { code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED }
|
|
: params.blocked.code === "security_scan_blocked"
|
|
? { code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function sourceFamilyForInstallPolicyKind(
|
|
kind: PluginInstallPolicyRequest["kind"] | undefined,
|
|
fallback: PluginSecuritySourceFamily,
|
|
): PluginSecuritySourceFamily {
|
|
switch (kind) {
|
|
case "plugin-archive":
|
|
return "archive";
|
|
case "plugin-dir":
|
|
return "directory";
|
|
case "plugin-git":
|
|
return "git";
|
|
case "plugin-npm":
|
|
return "npm";
|
|
case undefined:
|
|
return fallback;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export function sourceFamilyForInstallPolicySource(
|
|
source: InstallPolicySource | undefined,
|
|
fallback: PluginSecuritySourceFamily,
|
|
): PluginSecuritySourceFamily {
|
|
switch (source?.kind) {
|
|
case "archive":
|
|
return "archive";
|
|
case "file":
|
|
return "file";
|
|
case "git":
|
|
return "git";
|
|
case "npm":
|
|
return "npm";
|
|
case "bundled":
|
|
case "clawhub":
|
|
case "local-path":
|
|
case "managed":
|
|
case "upload":
|
|
case "workspace":
|
|
case undefined:
|
|
return fallback;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export type PreparedInstallTarget = {
|
|
targetPath: string;
|
|
effectiveMode: "install" | "update";
|
|
};
|
|
|
|
export async function ensureInstallTargetAvailableForMode(params: {
|
|
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
|
targetPath: string;
|
|
mode: "install" | "update";
|
|
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
return await params.runtime.ensureInstallTargetAvailable({
|
|
mode: params.mode,
|
|
targetDir: params.targetPath,
|
|
alreadyExistsError: `plugin already exists: ${params.targetPath} (delete it first)`,
|
|
});
|
|
}
|
|
|
|
export async function resolvePreparedDirectoryInstallTarget(params: {
|
|
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
|
pluginId: string;
|
|
extensionsDir?: string;
|
|
requestedMode: "install" | "update";
|
|
nameEncoder?: (pluginId: string) => string;
|
|
}): Promise<{ ok: true; target: PreparedInstallTarget } | { ok: false; error: string }> {
|
|
const targetDirResult = await resolvePluginInstallTarget({
|
|
runtime: params.runtime,
|
|
pluginId: params.pluginId,
|
|
extensionsDir: params.extensionsDir,
|
|
nameEncoder: params.nameEncoder,
|
|
});
|
|
if (!targetDirResult.ok) {
|
|
return targetDirResult;
|
|
}
|
|
return {
|
|
ok: true,
|
|
target: {
|
|
targetPath: targetDirResult.targetDir,
|
|
effectiveMode: await resolveEffectiveInstallMode({
|
|
runtime: params.runtime,
|
|
requestedMode: params.requestedMode,
|
|
targetPath: targetDirResult.targetDir,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function runInstallSourceScan(params: {
|
|
subject: string;
|
|
pluginId?: string;
|
|
mode?: "install" | "update";
|
|
sourceFamily?: PluginSecuritySourceFamily;
|
|
scan: () => Promise<InstallSecurityScanResult | undefined>;
|
|
}): Promise<Extract<InstallPluginResult, { ok: false }> | null> {
|
|
try {
|
|
const scanResult = await params.scan();
|
|
if (scanResult?.blocked) {
|
|
const reason =
|
|
scanResult.blocked.code === "security_scan_failed"
|
|
? "security_scan_failed"
|
|
: "security_scan_blocked";
|
|
emitPluginAuditSecurityEvent({
|
|
outcome: pluginAuditOutcomeForReason(reason),
|
|
reason,
|
|
pluginId: params.pluginId,
|
|
mode: params.mode,
|
|
sourceFamily: params.sourceFamily,
|
|
});
|
|
return buildBlockedInstallResult({ blocked: scanResult.blocked });
|
|
}
|
|
return null;
|
|
} catch (err) {
|
|
emitPluginAuditSecurityEvent({
|
|
outcome: "error",
|
|
reason: "security_scan_failed",
|
|
pluginId: params.pluginId,
|
|
mode: params.mode,
|
|
sourceFamily: params.sourceFamily,
|
|
});
|
|
return {
|
|
ok: false,
|
|
error: `${params.subject} installation blocked: code safety scan failed (${String(err)}). Run "openclaw security audit --deep" for details.`,
|
|
code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function installPluginDirectoryIntoExtensions(params: {
|
|
sourceDir: string;
|
|
pluginId: string;
|
|
manifestName?: string;
|
|
version?: string;
|
|
extensions: string[];
|
|
setup?: import("./manifest.js").PluginManifestSetup;
|
|
targetDir?: string;
|
|
extensionsDir?: string;
|
|
logger: PluginInstallLogger;
|
|
timeoutMs: number;
|
|
mode: "install" | "update";
|
|
dryRun: boolean;
|
|
copyErrorPrefix: string;
|
|
hasDeps: boolean;
|
|
sourceHardlinks?: "package-manager" | "reject";
|
|
depsLogMessage: string;
|
|
afterCopy?: (installedDir: string) => Promise<void>;
|
|
afterInstall?: (
|
|
installedDir: string,
|
|
) => Promise<Extract<InstallPluginResult, { ok: false }> | null>;
|
|
nameEncoder?: (pluginId: string) => string;
|
|
}): Promise<InstallPluginResult> {
|
|
const runtime = await loadPluginInstallRuntime();
|
|
let targetDir = params.targetDir;
|
|
if (!targetDir) {
|
|
const targetDirResult = await resolvePluginInstallTarget({
|
|
runtime,
|
|
pluginId: params.pluginId,
|
|
extensionsDir: params.extensionsDir,
|
|
nameEncoder: params.nameEncoder,
|
|
});
|
|
if (!targetDirResult.ok) {
|
|
return { ok: false, error: targetDirResult.error };
|
|
}
|
|
targetDir = targetDirResult.targetDir;
|
|
}
|
|
const availability = await ensureInstallTargetAvailableForMode({
|
|
runtime,
|
|
targetPath: targetDir,
|
|
mode: params.mode,
|
|
});
|
|
if (!availability.ok) {
|
|
return availability;
|
|
}
|
|
|
|
if (params.dryRun) {
|
|
return buildDirectoryInstallResult({
|
|
pluginId: params.pluginId,
|
|
targetDir,
|
|
manifestName: params.manifestName,
|
|
version: params.version,
|
|
extensions: params.extensions,
|
|
setup: params.setup,
|
|
});
|
|
}
|
|
|
|
const installRes = await runtime.installPackageDir({
|
|
sourceDir: params.sourceDir,
|
|
targetDir,
|
|
mode: params.mode,
|
|
timeoutMs: params.timeoutMs,
|
|
logger: params.logger,
|
|
copyErrorPrefix: params.copyErrorPrefix,
|
|
hasDeps: params.hasDeps,
|
|
sourceHardlinks: params.sourceHardlinks ?? "reject",
|
|
depsLogMessage: params.depsLogMessage,
|
|
afterCopy: params.afterCopy,
|
|
afterInstall: async (installedDir) => {
|
|
const postInstallResult = await params.afterInstall?.(installedDir);
|
|
if (!postInstallResult) {
|
|
return { ok: true as const };
|
|
}
|
|
return {
|
|
ok: false as const,
|
|
error: postInstallResult.error,
|
|
...(postInstallResult.code ? { code: postInstallResult.code } : {}),
|
|
};
|
|
},
|
|
});
|
|
if (!installRes.ok) {
|
|
return {
|
|
ok: false,
|
|
error: installRes.error,
|
|
...(installRes.code ? { code: installRes.code as PluginInstallErrorCode } : {}),
|
|
};
|
|
}
|
|
|
|
return buildDirectoryInstallResult({
|
|
pluginId: params.pluginId,
|
|
targetDir,
|
|
manifestName: params.manifestName,
|
|
version: params.version,
|
|
extensions: params.extensions,
|
|
setup: params.setup,
|
|
});
|
|
}
|
|
|
|
async function resolvePluginInstallTarget(params: {
|
|
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
|
pluginId: string;
|
|
extensionsDir?: string;
|
|
nameEncoder?: (pluginId: string) => string;
|
|
}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> {
|
|
const extensionsDir = params.extensionsDir
|
|
? resolveUserPath(params.extensionsDir)
|
|
: resolveDefaultPluginExtensionsDir();
|
|
return await params.runtime.resolveCanonicalInstallTarget({
|
|
baseDir: extensionsDir,
|
|
id: params.pluginId,
|
|
invalidNameMessage: "invalid plugin name: path traversal detected",
|
|
boundaryLabel: "extensions directory",
|
|
nameEncoder: params.nameEncoder,
|
|
});
|
|
}
|
|
|
|
export async function resolveEffectiveInstallMode(params: {
|
|
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
|
|
requestedMode: "install" | "update";
|
|
targetPath: string;
|
|
}): Promise<"install" | "update"> {
|
|
if (params.requestedMode !== "update") {
|
|
return "install";
|
|
}
|
|
return (await params.runtime.fileExists(params.targetPath)) ? "update" : "install";
|
|
}
|