mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-29 00:13:36 +00:00
fix(cli): harden official plugin recovery (#93325)
* fix(cli): harden official plugin recovery * fix(config): preserve include write context * fix(config): reject external include mutations * fix(config): bind snapshots to config paths * fix(config): preserve write ownership * fix(cli): preflight plugin config mutations * chore(plugin-sdk): refresh api baseline * test(config): prove install env policy mutations * fix(cli): preflight plugin updates * fix(cli): preflight non-npm id migrations * chore(plugin-sdk): refresh api baseline * fix(cli): satisfy plugin recovery checks
This commit is contained in:
@@ -3,10 +3,16 @@ import fs from "node:fs";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js";
|
||||
import {
|
||||
assertConfigWriteAllowedInCurrentMode,
|
||||
readConfigFileSnapshotForWrite,
|
||||
} from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js";
|
||||
import {
|
||||
installHooksFromNpmSpec,
|
||||
installHooksFromPath,
|
||||
type InstallHooksResult,
|
||||
} from "../hooks/install.js";
|
||||
import { resolveArchiveKind } from "../infra/archive.js";
|
||||
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
@@ -58,8 +64,21 @@ import {
|
||||
parseNpmPackPrefixPath,
|
||||
parseNpmPrefixSpec,
|
||||
} from "./plugins-command-helpers.js";
|
||||
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
|
||||
import type { ConfigSnapshotForInstallPersist } from "./plugins-install-persist.js";
|
||||
import {
|
||||
persistHookPackInstall,
|
||||
persistPluginInstall,
|
||||
resolveInstallConfigMutationPreflights,
|
||||
selectInstallMutationWriteOptions,
|
||||
supportsInstallConfigSingleTopLevelIncludeShape,
|
||||
type ConfigMutationPreflight,
|
||||
type ConfigSnapshotForInstallPersist,
|
||||
} from "./plugins-install-persist.js";
|
||||
import { listPersistedBundledPluginRecoveryLocations } from "./plugins-location-bridges.js";
|
||||
|
||||
type ConfigSnapshotForInstallExecution = ConfigSnapshotForInstallPersist & {
|
||||
hookMutation: ConfigMutationPreflight;
|
||||
pluginMutation: ConfigMutationPreflight;
|
||||
};
|
||||
|
||||
function resolveInstallMode(force?: boolean): "install" | "update" {
|
||||
return force ? "update" : "install";
|
||||
@@ -73,6 +92,26 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta
|
||||
};
|
||||
}
|
||||
|
||||
async function probeHookPackFromNpmSpec(
|
||||
params: Parameters<typeof installHooksFromNpmSpec>[0],
|
||||
): Promise<InstallHooksResult> {
|
||||
try {
|
||||
return await installHooksFromNpmSpec(params);
|
||||
} catch (error) {
|
||||
return { ok: false, error: formatErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function probeHookPackFromPath(
|
||||
params: Parameters<typeof installHooksFromPath>[0],
|
||||
): Promise<InstallHooksResult> {
|
||||
try {
|
||||
return await installHooksFromPath(params);
|
||||
} catch (error) {
|
||||
return { ok: false, error: formatErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const DEPRECATED_DANGEROUS_FORCE_UNSAFE_INSTALL_WARNING =
|
||||
"--dangerously-force-unsafe-install is deprecated and no longer affects plugin installs because built-in install-time dangerous-code scanning has been removed. Configure security.installPolicy for operator-owned install decisions.";
|
||||
|
||||
@@ -106,6 +145,31 @@ function isEmptyRecord(value: Record<string, unknown>): boolean {
|
||||
return Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
function supportsPluginRecoveryIncludeShape(parsed: Record<string, unknown>): boolean {
|
||||
if (Object.hasOwn(parsed, "$include")) {
|
||||
return false;
|
||||
}
|
||||
return supportsInstallConfigSingleTopLevelIncludeShape(parsed.plugins);
|
||||
}
|
||||
|
||||
function resolveFullyBlockedConfigMutationReason(
|
||||
snapshot: ConfigSnapshotForInstallExecution,
|
||||
): string | null {
|
||||
if (snapshot.pluginMutation.mode !== "blocked" || snapshot.hookMutation.mode !== "blocked") {
|
||||
return null;
|
||||
}
|
||||
if (snapshot.pluginMutation.reason === snapshot.hookMutation.reason) {
|
||||
return snapshot.pluginMutation.reason;
|
||||
}
|
||||
return `Config plugin and hook mutations are both blocked. ${snapshot.pluginMutation.reason} ${snapshot.hookMutation.reason}`;
|
||||
}
|
||||
|
||||
function assertPluginConfigMutationAllowed(preflight: ConfigMutationPreflight): void {
|
||||
if (preflight.mode === "blocked") {
|
||||
throw buildInvalidPluginInstallConfigError(preflight.reason);
|
||||
}
|
||||
}
|
||||
|
||||
function hasValidBundledPluginConfig(params: {
|
||||
bundledSource: BundledPluginSource;
|
||||
existingEntry: unknown;
|
||||
@@ -147,7 +211,7 @@ function prepareConfigForDisabledBundledInstall(
|
||||
}
|
||||
|
||||
async function installBundledPluginSource(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
snapshot: ConfigSnapshotForInstallExecution;
|
||||
rawSpec: string;
|
||||
bundledSource: BundledPluginSource;
|
||||
warning: string;
|
||||
@@ -168,8 +232,8 @@ async function installBundledPluginSource(params: {
|
||||
: `Installed bundled plugin "${params.bundledSource.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.bundledSource.pluginId}\`.`;
|
||||
await persistPluginInstall({
|
||||
snapshot: {
|
||||
...params.snapshot,
|
||||
config: configBase,
|
||||
baseHash: params.snapshot.baseHash,
|
||||
},
|
||||
pluginId: params.bundledSource.pluginId,
|
||||
install: {
|
||||
@@ -186,13 +250,17 @@ async function installBundledPluginSource(params: {
|
||||
}
|
||||
|
||||
async function tryInstallHookPackFromLocalPath(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
snapshot: ConfigSnapshotForInstallExecution;
|
||||
resolvedPath: string;
|
||||
installMode: "install" | "update";
|
||||
safetyOverrides?: InstallSafetyOverrides;
|
||||
link?: boolean;
|
||||
expectedPackageKind?: "hook-only";
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (params.snapshot.hookMutation.mode === "blocked") {
|
||||
return { ok: false, error: params.snapshot.hookMutation.reason };
|
||||
}
|
||||
if (params.link) {
|
||||
const stat = fs.statSync(params.resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
@@ -206,6 +274,7 @@ async function tryInstallHookPackFromLocalPath(params: {
|
||||
...resolveInstallSafetyOverrides(params.safetyOverrides ?? {}),
|
||||
path: params.resolvedPath,
|
||||
dryRun: true,
|
||||
...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}),
|
||||
});
|
||||
if (!probe.ok) {
|
||||
return probe;
|
||||
@@ -215,6 +284,7 @@ async function tryInstallHookPackFromLocalPath(params: {
|
||||
const merged = uniqueStrings([...existing, params.resolvedPath]);
|
||||
await persistHookPackInstall({
|
||||
snapshot: {
|
||||
...params.snapshot,
|
||||
config: {
|
||||
...params.snapshot.config,
|
||||
hooks: {
|
||||
@@ -229,7 +299,6 @@ async function tryInstallHookPackFromLocalPath(params: {
|
||||
},
|
||||
},
|
||||
},
|
||||
baseHash: params.snapshot.baseHash,
|
||||
},
|
||||
hookPackId: probe.hookPackId,
|
||||
hooks: probe.hooks,
|
||||
@@ -249,6 +318,7 @@ async function tryInstallHookPackFromLocalPath(params: {
|
||||
...resolveInstallSafetyOverrides(params.safetyOverrides ?? {}),
|
||||
path: params.resolvedPath,
|
||||
mode: params.installMode,
|
||||
...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}),
|
||||
logger: createHookPackInstallLogger(params.runtime),
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -272,17 +342,23 @@ async function tryInstallHookPackFromLocalPath(params: {
|
||||
}
|
||||
|
||||
async function tryInstallHookPackFromNpmSpec(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
snapshot: ConfigSnapshotForInstallExecution;
|
||||
installMode: "install" | "update";
|
||||
spec: string;
|
||||
pin?: boolean;
|
||||
expectedIntegrity?: string;
|
||||
expectedPackageKind?: "hook-only";
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (params.snapshot.hookMutation.mode === "blocked") {
|
||||
return { ok: false, error: params.snapshot.hookMutation.reason };
|
||||
}
|
||||
const result = await installHooksFromNpmSpec({
|
||||
config: params.snapshot.config,
|
||||
spec: params.spec,
|
||||
mode: params.installMode,
|
||||
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
|
||||
...(params.expectedPackageKind ? { expectedPackageKind: params.expectedPackageKind } : {}),
|
||||
logger: createHookPackInstallLogger(params.runtime),
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -309,7 +385,7 @@ async function tryInstallHookPackFromNpmSpec(params: {
|
||||
}
|
||||
|
||||
async function tryInstallPluginOrHookPackFromNpmSpec(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
snapshot: ConfigSnapshotForInstallExecution;
|
||||
installMode: "install" | "update";
|
||||
spec: string;
|
||||
pin?: boolean;
|
||||
@@ -322,6 +398,49 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
|
||||
invalidateRuntimeCache?: boolean;
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<{ ok: true } | { ok: false }> {
|
||||
const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(params.snapshot);
|
||||
if (fullyBlockedReason) {
|
||||
(params.runtime ?? defaultRuntime).error(fullyBlockedReason);
|
||||
return { ok: false };
|
||||
}
|
||||
if (
|
||||
params.snapshot.pluginMutation.mode === "blocked" ||
|
||||
params.snapshot.hookMutation.mode === "blocked"
|
||||
) {
|
||||
const hookProbe = await probeHookPackFromNpmSpec({
|
||||
config: params.snapshot.config,
|
||||
spec: params.spec,
|
||||
mode: params.installMode,
|
||||
inspection: "package-kind",
|
||||
...(params.expectedIntegrity ? { expectedIntegrity: params.expectedIntegrity } : {}),
|
||||
logger: createHookPackInstallLogger(params.runtime),
|
||||
});
|
||||
if (hookProbe.ok && hookProbe.packageKind === "hook-only") {
|
||||
if (params.snapshot.hookMutation.mode === "blocked") {
|
||||
(params.runtime ?? defaultRuntime).error(params.snapshot.hookMutation.reason);
|
||||
return { ok: false };
|
||||
}
|
||||
const hookFallback = await tryInstallHookPackFromNpmSpec({
|
||||
snapshot: params.snapshot,
|
||||
installMode: params.installMode,
|
||||
spec: params.spec,
|
||||
pin: params.pin,
|
||||
expectedIntegrity: hookProbe.npmResolution?.integrity ?? params.expectedIntegrity,
|
||||
expectedPackageKind: "hook-only",
|
||||
runtime: params.runtime,
|
||||
});
|
||||
if (hookFallback.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
(params.runtime ?? defaultRuntime).error(hookFallback.error);
|
||||
return { ok: false };
|
||||
}
|
||||
if (params.snapshot.pluginMutation.mode === "blocked") {
|
||||
(params.runtime ?? defaultRuntime).error(params.snapshot.pluginMutation.reason);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
...params.safetyOverrides,
|
||||
mode: params.installMode,
|
||||
@@ -394,7 +513,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
|
||||
}
|
||||
|
||||
async function tryInstallPluginFromNpmPackArchive(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
snapshot: ConfigSnapshotForInstallExecution;
|
||||
installMode: "install" | "update";
|
||||
archivePath: string;
|
||||
safetyOverrides: InstallSafetyOverrides;
|
||||
@@ -444,7 +563,7 @@ async function tryInstallPluginFromNpmPackArchive(params: {
|
||||
}
|
||||
|
||||
async function tryInstallPluginFromGitSpec(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
snapshot: ConfigSnapshotForInstallExecution;
|
||||
installMode: "install" | "update";
|
||||
spec: string;
|
||||
safetyOverrides: InstallSafetyOverrides;
|
||||
@@ -494,8 +613,7 @@ function isTerminalPluginInstallFailure(code?: string): boolean {
|
||||
function isAllowedPluginRecoveryIssue(
|
||||
issue: { path?: string; message?: string },
|
||||
request: PluginInstallRequestContext,
|
||||
installRecords: Record<string, PluginInstallRecord>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
ownedLoadPaths: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const pluginId = request.bundledPluginId?.trim();
|
||||
if (!pluginId) {
|
||||
@@ -504,9 +622,7 @@ function isAllowedPluginRecoveryIssue(
|
||||
return (
|
||||
(issue.path === `channels.${pluginId}` &&
|
||||
issue.message === `unknown channel id: ${pluginId}`) ||
|
||||
(issue.path === "plugins.load.paths" &&
|
||||
typeof issue.message === "string" &&
|
||||
isMissingPluginLoadPathForInstallRecord({ issue, installRecords, pluginId, env })) ||
|
||||
isOwnedMissingPluginLoadPathIssue(issue, ownedLoadPaths) ||
|
||||
(issue.path === `plugins.entries.${pluginId}` &&
|
||||
typeof issue.message === "string" &&
|
||||
issue.message.includes("requires compiled runtime output")) ||
|
||||
@@ -522,21 +638,6 @@ function buildInvalidPluginInstallConfigError(message: string): Error {
|
||||
return error;
|
||||
}
|
||||
|
||||
function hasConfigInclude(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((child) => hasConfigInclude(child));
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
if (Object.hasOwn(value, "$include")) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(value).some((child) => hasConfigInclude(child));
|
||||
}
|
||||
|
||||
const ENV_VAR_REFERENCE_RE = /\$\{[A-Z_][A-Z0-9_]*\}/;
|
||||
|
||||
function extractMissingPluginLoadPath(issue: { path?: string; message?: string }): string | null {
|
||||
if (issue.path !== "plugins.load.paths" || typeof issue.message !== "string") {
|
||||
return null;
|
||||
@@ -550,116 +651,68 @@ function extractMissingPluginLoadPath(issue: { path?: string; message?: string }
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function resolvePluginInstallRecordPaths(params: {
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
pluginId: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Set<string> {
|
||||
const install = params.installRecords[params.pluginId];
|
||||
function collectRequestedPluginInstallPaths(
|
||||
cfg: OpenClawConfig,
|
||||
installRecords: Awaited<ReturnType<typeof loadInstalledPluginIndexInstallRecords>>,
|
||||
request: PluginInstallRequestContext,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Set<string> {
|
||||
const pluginId = request.bundledPluginId?.trim();
|
||||
if (!pluginId) {
|
||||
return new Set();
|
||||
}
|
||||
const paths = new Set<string>();
|
||||
for (const value of [install?.installPath, install?.sourcePath]) {
|
||||
const record = installRecords[pluginId] ?? cfg.plugins?.installs?.[pluginId];
|
||||
for (const value of [record?.sourcePath, record?.installPath]) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
paths.add(resolveUserPath(value, params.env));
|
||||
paths.add(resolveUserPath(value, env));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isMissingPluginLoadPathForInstallRecord(params: {
|
||||
issue: { path?: string; message?: string };
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
pluginId: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const missingPath = extractMissingPluginLoadPath(params.issue);
|
||||
if (!missingPath) {
|
||||
return false;
|
||||
}
|
||||
return resolvePluginInstallRecordPaths(params).has(resolveUserPath(missingPath, params.env));
|
||||
function isOwnedMissingPluginLoadPathIssue(
|
||||
issue: { path?: string; message?: string },
|
||||
ownedLoadPaths: ReadonlySet<string>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
const missingPath = extractMissingPluginLoadPath(issue);
|
||||
return missingPath !== null && ownedLoadPaths.has(resolveUserPath(missingPath, env));
|
||||
}
|
||||
|
||||
function readPluginLoadPathEntries(cfg: unknown): unknown[] | undefined {
|
||||
if (!isRecord(cfg) || !isRecord(cfg.plugins) || !isRecord(cfg.plugins.load)) {
|
||||
return undefined;
|
||||
async function collectRequestedPluginLocationBridgePaths(
|
||||
request: PluginInstallRequestContext,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<Set<string>> {
|
||||
const pluginId = request.bundledPluginId?.trim();
|
||||
if (!pluginId) {
|
||||
return new Set();
|
||||
}
|
||||
const paths = cfg.plugins.load.paths;
|
||||
return Array.isArray(paths) ? paths : undefined;
|
||||
}
|
||||
|
||||
function arrayHasEnvRef(value: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.some((entry) => typeof entry === "string" && ENV_VAR_REFERENCE_RE.test(entry))
|
||||
const locations = await listPersistedBundledPluginRecoveryLocations({ env });
|
||||
return new Set(
|
||||
locations
|
||||
.filter((location) => location.pluginId === pluginId)
|
||||
.flatMap((location) => location.loadPaths.map((loadPath) => resolveUserPath(loadPath, env))),
|
||||
);
|
||||
}
|
||||
|
||||
function hasAuthoredPluginPolicyEnvRefs(params: {
|
||||
authoredConfig: unknown;
|
||||
resolvedConfig: OpenClawConfig;
|
||||
pluginId: string;
|
||||
}): boolean {
|
||||
if (!isRecord(params.authoredConfig) || !isRecord(params.authoredConfig.plugins)) {
|
||||
return false;
|
||||
}
|
||||
const resolvedPlugins = params.resolvedConfig.plugins;
|
||||
const allowWillChange =
|
||||
Array.isArray(resolvedPlugins?.allow) &&
|
||||
resolvedPlugins.allow.length > 0 &&
|
||||
!resolvedPlugins.allow.includes(params.pluginId);
|
||||
if (allowWillChange && arrayHasEnvRef(params.authoredConfig.plugins.allow)) {
|
||||
return true;
|
||||
}
|
||||
const denyWillChange =
|
||||
Array.isArray(resolvedPlugins?.deny) && resolvedPlugins.deny.includes(params.pluginId);
|
||||
return denyWillChange && arrayHasEnvRef(params.authoredConfig.plugins.deny);
|
||||
}
|
||||
|
||||
function wouldMoveAuthoredEnvPluginLoadPath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
issues: readonly { path?: string; message?: string }[];
|
||||
authoredConfig: unknown;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const missingPaths = new Set(
|
||||
params.issues
|
||||
.map(extractMissingPluginLoadPath)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => resolveUserPath(value, params.env)),
|
||||
);
|
||||
const paths = params.cfg.plugins?.load?.paths;
|
||||
const authoredPaths = readPluginLoadPathEntries(params.authoredConfig);
|
||||
if (missingPaths.size === 0 || !Array.isArray(paths) || !Array.isArray(authoredPaths)) {
|
||||
return false;
|
||||
}
|
||||
let removedBefore = false;
|
||||
for (const [index, entry] of paths.entries()) {
|
||||
if (typeof entry === "string" && missingPaths.has(resolveUserPath(entry, params.env))) {
|
||||
removedBefore = true;
|
||||
continue;
|
||||
}
|
||||
const authoredEntry = authoredPaths[index];
|
||||
if (
|
||||
removedBefore &&
|
||||
typeof authoredEntry === "string" &&
|
||||
ENV_VAR_REFERENCE_RE.test(authoredEntry)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function removeMissingPluginLoadPaths(
|
||||
function removeOwnedMissingPluginLoadPaths(
|
||||
cfg: OpenClawConfig,
|
||||
issues: readonly { path?: string; message?: string }[],
|
||||
ownedLoadPaths: ReadonlySet<string>,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): OpenClawConfig {
|
||||
const missingPaths = new Set(
|
||||
issues
|
||||
.map(extractMissingPluginLoadPath)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => resolveUserPath(value, env)),
|
||||
);
|
||||
const missingPaths = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
const missingPath = extractMissingPluginLoadPath(issue);
|
||||
if (!missingPath) {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveUserPath(missingPath, env);
|
||||
if (ownedLoadPaths.has(resolved)) {
|
||||
missingPaths.add(resolved);
|
||||
}
|
||||
}
|
||||
const paths = cfg.plugins?.load?.paths;
|
||||
if (missingPaths.size === 0 || !Array.isArray(paths)) {
|
||||
return cfg;
|
||||
@@ -682,10 +735,38 @@ function removeMissingPluginLoadPaths(
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRequestedPluginInstallPaths(
|
||||
cfg: OpenClawConfig,
|
||||
issues: readonly { path?: string; message?: string }[],
|
||||
request: PluginInstallRequestContext,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<Set<string>> {
|
||||
if (!issues.some((issue) => extractMissingPluginLoadPath(issue) !== null)) {
|
||||
return new Set();
|
||||
}
|
||||
const installRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const ownedLoadPaths = collectRequestedPluginInstallPaths(cfg, installRecords, request, env);
|
||||
const stillNeedsLocationBridge = issues.some(
|
||||
(issue) =>
|
||||
extractMissingPluginLoadPath(issue) !== null &&
|
||||
!isOwnedMissingPluginLoadPathIssue(issue, ownedLoadPaths, env),
|
||||
);
|
||||
if (stillNeedsLocationBridge) {
|
||||
// The persisted bundled registry proves this plugin previously owned its
|
||||
// removed core path; do not infer ownership from the requested id alone.
|
||||
for (const loadPath of await collectRequestedPluginLocationBridgePaths(request, env)) {
|
||||
ownedLoadPaths.add(loadPath);
|
||||
}
|
||||
}
|
||||
return ownedLoadPaths;
|
||||
}
|
||||
|
||||
async function loadConfigFromSnapshotForInstall(
|
||||
request: PluginInstallRequestContext,
|
||||
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
|
||||
): Promise<ConfigSnapshotForInstallPersist> {
|
||||
prepared: Awaited<ReturnType<typeof readConfigFileSnapshotForWrite>>,
|
||||
): Promise<ConfigSnapshotForInstallExecution> {
|
||||
const { snapshot, writeOptions } = prepared;
|
||||
const mutationWriteOptions = selectInstallMutationWriteOptions(writeOptions);
|
||||
if (resolvePluginInstallInvalidConfigPolicy(request) !== "allow-plugin-recovery") {
|
||||
throw buildInvalidPluginInstallConfigError(
|
||||
"Config invalid; run `openclaw doctor --fix` before installing plugins.",
|
||||
@@ -697,77 +778,77 @@ async function loadConfigFromSnapshotForInstall(
|
||||
"Config file could not be parsed; run `openclaw doctor` to repair it.",
|
||||
);
|
||||
}
|
||||
const pluginId = request.bundledPluginId?.trim() ?? "";
|
||||
const pluginLabel = pluginId || "the requested plugin";
|
||||
if (hasConfigInclude(snapshot.parsed)) {
|
||||
throw buildInvalidPluginInstallConfigError(
|
||||
`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
hasAuthoredPluginPolicyEnvRefs({
|
||||
authoredConfig: snapshot.parsed,
|
||||
resolvedConfig: snapshot.config,
|
||||
pluginId,
|
||||
})
|
||||
) {
|
||||
throw buildInvalidPluginInstallConfigError(
|
||||
`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
|
||||
);
|
||||
}
|
||||
const persistedInstallRecords = await tracePluginLifecyclePhaseAsync(
|
||||
"install records load",
|
||||
() => loadInstalledPluginIndexInstallRecords(),
|
||||
{ command: "install" },
|
||||
const ownedLoadPaths = await resolveRequestedPluginInstallPaths(
|
||||
snapshot.config,
|
||||
snapshot.issues,
|
||||
request,
|
||||
process.env,
|
||||
);
|
||||
const installRecords = {
|
||||
...snapshot.config.plugins?.installs,
|
||||
...persistedInstallRecords,
|
||||
};
|
||||
if (
|
||||
snapshot.legacyIssues.length > 0 ||
|
||||
snapshot.issues.length === 0 ||
|
||||
snapshot.issues.some((issue) => !isAllowedPluginRecoveryIssue(issue, request, installRecords))
|
||||
snapshot.issues.some((issue) => !isAllowedPluginRecoveryIssue(issue, request, ownedLoadPaths))
|
||||
) {
|
||||
const pluginLabel = request.bundledPluginId ?? "the requested plugin";
|
||||
throw buildInvalidPluginInstallConfigError(
|
||||
`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
|
||||
);
|
||||
}
|
||||
let nextConfig = snapshot.config;
|
||||
if (
|
||||
wouldMoveAuthoredEnvPluginLoadPath({
|
||||
cfg: nextConfig,
|
||||
issues: snapshot.issues,
|
||||
authoredConfig: snapshot.parsed,
|
||||
env: process.env,
|
||||
})
|
||||
) {
|
||||
if (!supportsPluginRecoveryIncludeShape(parsed)) {
|
||||
throw buildInvalidPluginInstallConfigError(
|
||||
`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
|
||||
"Config plugin recovery uses an unsupported $include shape; use a single-file top-level plugins include or run `openclaw doctor --fix` before reinstalling it.",
|
||||
);
|
||||
}
|
||||
nextConfig = removeMissingPluginLoadPaths(nextConfig, snapshot.issues, process.env);
|
||||
const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({
|
||||
parsed,
|
||||
snapshotPath: snapshot.path,
|
||||
writeOptions: mutationWriteOptions,
|
||||
});
|
||||
assertPluginConfigMutationAllowed(pluginMutation);
|
||||
const nextConfig = removeOwnedMissingPluginLoadPaths(
|
||||
snapshot.config,
|
||||
snapshot.issues,
|
||||
ownedLoadPaths,
|
||||
process.env,
|
||||
);
|
||||
return {
|
||||
config: nextConfig,
|
||||
baseHash: snapshot.hash,
|
||||
writeOptions: mutationWriteOptions,
|
||||
hookMutation,
|
||||
pluginMutation,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadConfigForInstall(
|
||||
request: PluginInstallRequestContext,
|
||||
): Promise<ConfigSnapshotForInstallPersist> {
|
||||
const snapshot = await tracePluginLifecyclePhaseAsync(
|
||||
): Promise<ConfigSnapshotForInstallExecution> {
|
||||
const prepared = await tracePluginLifecyclePhaseAsync(
|
||||
"config read",
|
||||
() => readConfigFileSnapshot(),
|
||||
() => readConfigFileSnapshotForWrite(),
|
||||
{ command: "install" },
|
||||
);
|
||||
const { snapshot, writeOptions } = prepared;
|
||||
const mutationWriteOptions = selectInstallMutationWriteOptions(writeOptions);
|
||||
if (snapshot.valid) {
|
||||
const parsed = (snapshot.parsed ?? {}) as Record<string, unknown>;
|
||||
const { hookMutation, pluginMutation } = resolveInstallConfigMutationPreflights({
|
||||
parsed,
|
||||
snapshotPath: snapshot.path,
|
||||
writeOptions: mutationWriteOptions,
|
||||
});
|
||||
if (request.installKind === "plugin") {
|
||||
assertPluginConfigMutationAllowed(pluginMutation);
|
||||
}
|
||||
return {
|
||||
config: snapshot.sourceConfig,
|
||||
baseHash: snapshot.hash,
|
||||
writeOptions: mutationWriteOptions,
|
||||
hookMutation,
|
||||
pluginMutation,
|
||||
};
|
||||
}
|
||||
return loadConfigFromSnapshotForInstall(request, snapshot);
|
||||
return loadConfigFromSnapshotForInstall(request, prepared);
|
||||
}
|
||||
|
||||
export async function runPluginInstallCommand(params: {
|
||||
@@ -846,6 +927,8 @@ export async function runPluginInstallCommand(params: {
|
||||
);
|
||||
return runtime.exit(1);
|
||||
}
|
||||
const npmPackPath = parseNpmPackPrefixPath(raw);
|
||||
const clawhubSpec = parseClawHubPluginSpec(raw);
|
||||
const requestResolution = resolvePluginInstallRequestContext({
|
||||
rawSpec: raw,
|
||||
marketplace: opts.marketplace,
|
||||
@@ -854,7 +937,41 @@ export async function runPluginInstallCommand(params: {
|
||||
runtime.error(requestResolution.error);
|
||||
return runtime.exit(1);
|
||||
}
|
||||
const request = requestResolution.request;
|
||||
let request = requestResolution.request;
|
||||
const resolved = request.resolvedPath ?? request.normalizedSpec;
|
||||
const resolvesToLocalPath = fs.existsSync(resolved);
|
||||
if (!resolvesToLocalPath && (gitSpec || npmPackPath !== null || clawhubSpec)) {
|
||||
request = { ...request, installKind: "plugin" };
|
||||
}
|
||||
const bundledPreNpmPlan = resolvesToLocalPath
|
||||
? null
|
||||
: resolveBundledInstallPlanBeforeNpm({
|
||||
rawSpec: raw,
|
||||
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
|
||||
});
|
||||
const officialExternalPlan = resolvesToLocalPath
|
||||
? null
|
||||
: resolveOfficialExternalInstallPlanBeforeNpm({
|
||||
rawSpec: raw,
|
||||
findOfficialExternalPlugin: (pluginId) => {
|
||||
const entry = getOfficialExternalPluginCatalogEntry(pluginId);
|
||||
const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined;
|
||||
const install = entry ? resolveOfficialExternalPluginInstall(entry) : null;
|
||||
const npmSpec = install?.npmSpec;
|
||||
return resolvedPluginId && npmSpec
|
||||
? {
|
||||
pluginId: resolvedPluginId,
|
||||
npmSpec,
|
||||
...(install.expectedIntegrity
|
||||
? { expectedIntegrity: install.expectedIntegrity }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
});
|
||||
if (bundledPreNpmPlan || officialExternalPlan) {
|
||||
request = { ...request, installKind: "plugin" };
|
||||
}
|
||||
const snapshot = await loadConfigForInstall(request).catch((error: unknown) => {
|
||||
runtime.error(formatErrorMessage(error));
|
||||
return null;
|
||||
@@ -898,8 +1015,44 @@ export async function runPluginInstallCommand(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = request.resolvedPath ?? request.normalizedSpec;
|
||||
if (fs.existsSync(resolved)) {
|
||||
const fullyBlockedReason = resolveFullyBlockedConfigMutationReason(snapshot);
|
||||
if (fullyBlockedReason) {
|
||||
runtime.error(fullyBlockedReason);
|
||||
return runtime.exit(1);
|
||||
}
|
||||
if (snapshot.pluginMutation.mode === "blocked" || snapshot.hookMutation.mode === "blocked") {
|
||||
const hookProbe = await probeHookPackFromPath({
|
||||
...safetyOverrides,
|
||||
path: resolved,
|
||||
mode: installMode,
|
||||
inspection: "package-kind",
|
||||
});
|
||||
if (hookProbe.ok && hookProbe.packageKind === "hook-only") {
|
||||
if (snapshot.hookMutation.mode === "blocked") {
|
||||
runtime.error(snapshot.hookMutation.reason);
|
||||
return runtime.exit(1);
|
||||
}
|
||||
const hookFallback = await tryInstallHookPackFromLocalPath({
|
||||
snapshot,
|
||||
installMode,
|
||||
resolvedPath: resolved,
|
||||
safetyOverrides,
|
||||
...(opts.link ? { link: true } : {}),
|
||||
expectedPackageKind: "hook-only",
|
||||
runtime,
|
||||
});
|
||||
if (hookFallback.ok) {
|
||||
return;
|
||||
}
|
||||
runtime.error(hookFallback.error);
|
||||
return runtime.exit(1);
|
||||
}
|
||||
if (snapshot.pluginMutation.mode === "blocked") {
|
||||
runtime.error(snapshot.pluginMutation.reason);
|
||||
return runtime.exit(1);
|
||||
}
|
||||
}
|
||||
if (opts.link) {
|
||||
const existing = cfg.plugins?.load?.paths ?? [];
|
||||
const merged = uniqueStrings([...existing, resolved]);
|
||||
@@ -934,6 +1087,7 @@ export async function runPluginInstallCommand(params: {
|
||||
|
||||
await persistPluginInstall({
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
config: {
|
||||
...cfg,
|
||||
plugins: {
|
||||
@@ -944,7 +1098,6 @@ export async function runPluginInstallCommand(params: {
|
||||
},
|
||||
},
|
||||
},
|
||||
baseHash: snapshot.baseHash,
|
||||
},
|
||||
pluginId: probe.pluginId,
|
||||
install: {
|
||||
@@ -1047,7 +1200,6 @@ export async function runPluginInstallCommand(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const npmPackPath = parseNpmPackPrefixPath(raw);
|
||||
if (npmPackPath !== null) {
|
||||
if (!npmPackPath) {
|
||||
runtime.error(
|
||||
@@ -1104,10 +1256,6 @@ export async function runPluginInstallCommand(params: {
|
||||
return runtime.exit(1);
|
||||
}
|
||||
|
||||
const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({
|
||||
rawSpec: raw,
|
||||
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
|
||||
});
|
||||
if (bundledPreNpmPlan) {
|
||||
await tracePluginLifecyclePhaseAsync(
|
||||
"install execution",
|
||||
@@ -1129,22 +1277,6 @@ export async function runPluginInstallCommand(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const officialExternalPlan = resolveOfficialExternalInstallPlanBeforeNpm({
|
||||
rawSpec: raw,
|
||||
findOfficialExternalPlugin: (pluginId) => {
|
||||
const entry = getOfficialExternalPluginCatalogEntry(pluginId);
|
||||
const resolvedPluginId = entry ? resolveOfficialExternalPluginId(entry) : undefined;
|
||||
const install = entry ? resolveOfficialExternalPluginInstall(entry) : null;
|
||||
const npmSpec = install?.npmSpec;
|
||||
return resolvedPluginId && npmSpec
|
||||
? {
|
||||
pluginId: resolvedPluginId,
|
||||
npmSpec,
|
||||
...(install.expectedIntegrity ? { expectedIntegrity: install.expectedIntegrity } : {}),
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
});
|
||||
if (officialExternalPlan) {
|
||||
const npmResult = await tryInstallPluginOrHookPackFromNpmSpec({
|
||||
snapshot,
|
||||
@@ -1166,7 +1298,6 @@ export async function runPluginInstallCommand(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const clawhubSpec = parseClawHubPluginSpec(raw);
|
||||
if (clawhubSpec) {
|
||||
const result = await installPluginFromClawHub({
|
||||
...safetyOverrides,
|
||||
|
||||
Reference in New Issue
Block a user