




















@@ -3,9 +3,9 @@ import fs from "node:fs";
33import { isRecord } from "@openclaw/normalization-core/record-coerce";
44import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
55import { theme } from "../../packages/terminal-core/src/theme.js";
6-import { collectChannelDoctorStaleConfigMutations } from "../commands/doctor/shared/channel-doctor.js";
76import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js";
87import type { OpenClawConfig } from "../config/types.openclaw.js";
8+import type { PluginInstallRecord } from "../config/types.plugins.js";
99import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js";
1010import { resolveArchiveKind } from "../infra/archive.js";
1111import { parseClawHubPluginSpec } from "../infra/clawhub.js";
@@ -22,6 +22,7 @@ import {
2222installPluginFromNpmSpec,
2323installPluginFromPath,
2424} from "../plugins/install.js";
25+import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js";
2526import {
2627installPluginFromMarketplace,
2728resolveMarketplaceInstallShortcut,
@@ -35,7 +36,7 @@ import {
3536import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
3637import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
3738import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
38-import { shortenHomePath } from "../utils.js";
39+import { resolveUserPath, shortenHomePath } from "../utils.js";
3940import { formatCliCommand } from "./command-format.js";
4041import { looksLikeLocalInstallSpec } from "./install-spec.js";
4142import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
@@ -493,6 +494,8 @@ function isTerminalPluginInstallFailure(code?: string): boolean {
493494function isAllowedPluginRecoveryIssue(
494495issue: { path?: string; message?: string },
495496request: PluginInstallRequestContext,
497+installRecords: Record<string, PluginInstallRecord>,
498+env: NodeJS.ProcessEnv = process.env,
496499): boolean {
497500const pluginId = request.bundledPluginId?.trim();
498501if (!pluginId) {
@@ -503,10 +506,7 @@ function isAllowedPluginRecoveryIssue(
503506issue.message === `unknown channel id: ${pluginId}`) ||
504507(issue.path === "plugins.load.paths" &&
505508typeof issue.message === "string" &&
506-issue.message.includes("plugin path not found")) ||
507-(issue.path === "plugins" &&
508-typeof issue.message === "string" &&
509-issue.message.includes("requires compiled runtime output")) ||
509+isMissingPluginLoadPathForInstallRecord({ issue, installRecords, pluginId, env })) ||
510510(issue.path === `plugins.entries.${pluginId}` &&
511511typeof issue.message === "string" &&
512512issue.message.includes("requires compiled runtime output")) ||
@@ -522,6 +522,166 @@ function buildInvalidPluginInstallConfigError(message: string): Error {
522522return error;
523523}
524524525+function hasConfigInclude(value: unknown): boolean {
526+if (Array.isArray(value)) {
527+return value.some((child) => hasConfigInclude(child));
528+}
529+if (!isRecord(value)) {
530+return false;
531+}
532+if (Object.hasOwn(value, "$include")) {
533+return true;
534+}
535+return Object.values(value).some((child) => hasConfigInclude(child));
536+}
537+538+const ENV_VAR_REFERENCE_RE = /\$\{[A-Z_][A-Z0-9_]*\}/;
539+540+function extractMissingPluginLoadPath(issue: { path?: string; message?: string }): string | null {
541+if (issue.path !== "plugins.load.paths" || typeof issue.message !== "string") {
542+return null;
543+}
544+const marker = "plugin path not found:";
545+const markerIndex = issue.message.indexOf(marker);
546+if (markerIndex < 0) {
547+return null;
548+}
549+const value = issue.message.slice(markerIndex + marker.length).trim();
550+return value || null;
551+}
552+553+function resolvePluginInstallRecordPaths(params: {
554+installRecords: Record<string, PluginInstallRecord>;
555+pluginId: string;
556+env: NodeJS.ProcessEnv;
557+}): Set<string> {
558+const install = params.installRecords[params.pluginId];
559+const paths = new Set<string>();
560+for (const value of [install?.installPath, install?.sourcePath]) {
561+if (typeof value === "string" && value.trim()) {
562+paths.add(resolveUserPath(value, params.env));
563+}
564+}
565+return paths;
566+}
567+568+function isMissingPluginLoadPathForInstallRecord(params: {
569+issue: { path?: string; message?: string };
570+installRecords: Record<string, PluginInstallRecord>;
571+pluginId: string;
572+env: NodeJS.ProcessEnv;
573+}): boolean {
574+const missingPath = extractMissingPluginLoadPath(params.issue);
575+if (!missingPath) {
576+return false;
577+}
578+return resolvePluginInstallRecordPaths(params).has(resolveUserPath(missingPath, params.env));
579+}
580+581+function readPluginLoadPathEntries(cfg: unknown): unknown[] | undefined {
582+if (!isRecord(cfg) || !isRecord(cfg.plugins) || !isRecord(cfg.plugins.load)) {
583+return undefined;
584+}
585+const paths = cfg.plugins.load.paths;
586+return Array.isArray(paths) ? paths : undefined;
587+}
588+589+function arrayHasEnvRef(value: unknown): boolean {
590+return (
591+Array.isArray(value) &&
592+value.some((entry) => typeof entry === "string" && ENV_VAR_REFERENCE_RE.test(entry))
593+);
594+}
595+596+function hasAuthoredPluginPolicyEnvRefs(params: {
597+authoredConfig: unknown;
598+resolvedConfig: OpenClawConfig;
599+pluginId: string;
600+}): boolean {
601+if (!isRecord(params.authoredConfig) || !isRecord(params.authoredConfig.plugins)) {
602+return false;
603+}
604+const resolvedPlugins = params.resolvedConfig.plugins;
605+const allowWillChange =
606+Array.isArray(resolvedPlugins?.allow) &&
607+resolvedPlugins.allow.length > 0 &&
608+!resolvedPlugins.allow.includes(params.pluginId);
609+if (allowWillChange && arrayHasEnvRef(params.authoredConfig.plugins.allow)) {
610+return true;
611+}
612+const denyWillChange =
613+Array.isArray(resolvedPlugins?.deny) && resolvedPlugins.deny.includes(params.pluginId);
614+return denyWillChange && arrayHasEnvRef(params.authoredConfig.plugins.deny);
615+}
616+617+function wouldMoveAuthoredEnvPluginLoadPath(params: {
618+cfg: OpenClawConfig;
619+issues: readonly { path?: string; message?: string }[];
620+authoredConfig: unknown;
621+env: NodeJS.ProcessEnv;
622+}): boolean {
623+const missingPaths = new Set(
624+params.issues
625+.map(extractMissingPluginLoadPath)
626+.filter((value): value is string => Boolean(value))
627+.map((value) => resolveUserPath(value, params.env)),
628+);
629+const paths = params.cfg.plugins?.load?.paths;
630+const authoredPaths = readPluginLoadPathEntries(params.authoredConfig);
631+if (missingPaths.size === 0 || !Array.isArray(paths) || !Array.isArray(authoredPaths)) {
632+return false;
633+}
634+let removedBefore = false;
635+for (const [index, entry] of paths.entries()) {
636+if (typeof entry === "string" && missingPaths.has(resolveUserPath(entry, params.env))) {
637+removedBefore = true;
638+continue;
639+}
640+const authoredEntry = authoredPaths[index];
641+if (
642+removedBefore &&
643+typeof authoredEntry === "string" &&
644+ENV_VAR_REFERENCE_RE.test(authoredEntry)
645+) {
646+return true;
647+}
648+}
649+return false;
650+}
651+652+function removeMissingPluginLoadPaths(
653+cfg: OpenClawConfig,
654+issues: readonly { path?: string; message?: string }[],
655+env: NodeJS.ProcessEnv = process.env,
656+): OpenClawConfig {
657+const missingPaths = new Set(
658+issues
659+.map(extractMissingPluginLoadPath)
660+.filter((value): value is string => Boolean(value))
661+.map((value) => resolveUserPath(value, env)),
662+);
663+const paths = cfg.plugins?.load?.paths;
664+if (missingPaths.size === 0 || !Array.isArray(paths)) {
665+return cfg;
666+}
667+const nextPaths = paths.filter(
668+(entry) => typeof entry !== "string" || !missingPaths.has(resolveUserPath(entry, env)),
669+);
670+if (nextPaths.length === paths.length) {
671+return cfg;
672+}
673+return {
674+ ...cfg,
675+plugins: {
676+ ...cfg.plugins,
677+load: {
678+ ...cfg.plugins?.load,
679+paths: nextPaths,
680+},
681+},
682+};
683+}
684+525685async function loadConfigFromSnapshotForInstall(
526686request: PluginInstallRequestContext,
527687snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
@@ -537,22 +697,56 @@ async function loadConfigFromSnapshotForInstall(
537697"Config file could not be parsed; run `openclaw doctor` to repair it.",
538698);
539699}
700+const pluginId = request.bundledPluginId?.trim() ?? "";
701+const pluginLabel = pluginId || "the requested plugin";
702+if (hasConfigInclude(snapshot.parsed)) {
703+throw buildInvalidPluginInstallConfigError(
704+`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
705+);
706+}
707+if (
708+hasAuthoredPluginPolicyEnvRefs({
709+authoredConfig: snapshot.parsed,
710+resolvedConfig: snapshot.config,
711+ pluginId,
712+})
713+) {
714+throw buildInvalidPluginInstallConfigError(
715+`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
716+);
717+}
718+const persistedInstallRecords = await tracePluginLifecyclePhaseAsync(
719+"install records load",
720+() => loadInstalledPluginIndexInstallRecords(),
721+{ command: "install" },
722+);
723+const installRecords = {
724+ ...snapshot.config.plugins?.installs,
725+ ...persistedInstallRecords,
726+};
540727if (
541728snapshot.legacyIssues.length > 0 ||
542729snapshot.issues.length === 0 ||
543-snapshot.issues.some((issue) => !isAllowedPluginRecoveryIssue(issue, request))
730+snapshot.issues.some((issue) => !isAllowedPluginRecoveryIssue(issue, request, installRecords))
544731) {
545-const pluginLabel = request.bundledPluginId ?? "the requested plugin";
546732throw buildInvalidPluginInstallConfigError(
547733`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
548734);
549735}
550736let nextConfig = snapshot.config;
551-for (const mutation of await collectChannelDoctorStaleConfigMutations(snapshot.config, {
552-env: process.env,
553-})) {
554-nextConfig = mutation.config;
737+if (
738+wouldMoveAuthoredEnvPluginLoadPath({
739+cfg: nextConfig,
740+issues: snapshot.issues,
741+authoredConfig: snapshot.parsed,
742+env: process.env,
743+})
744+) {
745+throw buildInvalidPluginInstallConfigError(
746+`Config invalid outside the plugin recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`,
747+);
555748}
749+nextConfig = removeMissingPluginLoadPaths(nextConfig, snapshot.issues, process.env);
556750return {
557751config: nextConfig,
558752baseHash: snapshot.hash,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。