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:
Vincent Koc
2026-06-15 23:07:29 +08:00
committed by GitHub
parent c1219d161d
commit 767e8280ac
39 changed files with 9380 additions and 898 deletions
@@ -674,6 +674,21 @@ describe("plugin index install records store", () => {
});
});
it("preserves an authored empty plugins section while stripping transient install records", () => {
expect(
withoutPluginInstallRecords(
{
plugins: {
installs: {
twitch: { source: "npm", spec: "twitch@1.0.0" },
},
},
},
{ preserveEmptyPlugins: true },
),
).toEqual({ plugins: {} });
});
it("returns empty records when the persisted plugin index is missing", async () => {
const stateDir = makeStateDir();
@@ -87,12 +87,18 @@ export function withPluginInstallRecords(
}
/** Returns config with legacy plugin install records removed. */
export function withoutPluginInstallRecords(config: OpenClawConfig): OpenClawConfig {
export function withoutPluginInstallRecords(
config: OpenClawConfig,
options: { preserveEmptyPlugins?: boolean } = {},
): OpenClawConfig {
if (!config.plugins?.installs) {
return config;
}
const { installs: _installs, ...plugins } = config.plugins;
if (Object.keys(plugins).length === 0) {
if (options.preserveEmptyPlugins) {
return { ...config, plugins: {} };
}
const { plugins: _plugins, ...rest } = config;
return rest;
}
+35
View File
@@ -3613,6 +3613,41 @@ describe("updateNpmInstalledPlugins", () => {
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
});
it("keeps authored plugin config shape when only the install key migrates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
pluginId: "@openclaw/voice-call",
targetDir: "/tmp/openclaw-voice-call",
version: "0.0.2",
extensions: ["index.ts"],
});
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
installs: {
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call",
installPath: "/tmp/voice-call",
},
},
},
},
pluginIds: ["voice-call"],
});
expect(result.config.plugins).toEqual({
installs: {
"@openclaw/voice-call": expect.objectContaining({
source: "npm",
spec: "@openclaw/voice-call",
installPath: "/tmp/openclaw-voice-call",
}),
},
});
});
it("migrates context engine slot when a plugin id changes during update", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
+75 -35
View File
@@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { satisfiesPluginApiRange } from "../infra/clawhub.js";
import { unscopedPackageName } from "../infra/install-safe-path.js";
import type { NpmSpecResolution } from "../infra/install-source-utils.js";
import { createNpmMetadataEnv, resolveNpmSpecMetadata } from "../infra/install-source-utils.js";
import {
@@ -126,6 +127,43 @@ export type PluginChannelSyncResult = {
summary: PluginChannelSyncSummary;
};
/** Return whether a tracked plugin install source can be updated in place. */
export function isPluginInstallRecordUpdateSource(
record: PluginInstallRecord | undefined,
): boolean {
return (
record?.source === "npm" ||
record?.source === "marketplace" ||
record?.source === "clawhub" ||
record?.source === "git"
);
}
/** Return whether update identity compatibility can migrate an unscoped install key. */
export function pluginInstallRecordMayMigrateConfigId(params: {
pluginId: string;
record: PluginInstallRecord | undefined;
specOverride?: string;
}): boolean {
if (!isPluginInstallRecordUpdateSource(params.record)) {
return false;
}
if (params.record?.source !== "npm") {
// Generic package/archive installers can resolve an unscoped tracked key
// to a scoped package id; the exact package identity is unavailable preflight.
return !params.pluginId.includes("/");
}
const packageName =
resolveNpmSpecPackageName(params.specOverride ?? params.record.spec) ??
params.record.resolvedName ??
resolveNpmSpecPackageName(params.record.resolvedSpec);
return Boolean(
packageName &&
packageName !== params.pluginId &&
unscopedPackageName(packageName) === params.pluginId,
);
}
function formatNpmInstallFailure(params: {
pluginId: string;
spec: string;
@@ -961,7 +999,7 @@ function replacePluginIdInList(
fromId: string,
toId: string,
): string[] | undefined {
if (!entries || entries.length === 0 || fromId === toId) {
if (!entries || entries.length === 0 || fromId === toId || !entries.includes(fromId)) {
return entries;
}
const next: string[] = [];
@@ -975,27 +1013,33 @@ function replacePluginIdInList(
}
function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string): OpenClawConfig {
if (fromId === toId) {
const plugins = cfg.plugins;
if (fromId === toId || !plugins) {
return cfg;
}
const installs = cfg.plugins?.installs;
const entries = cfg.plugins?.entries;
const slots = cfg.plugins?.slots;
const allow = replacePluginIdInList(cfg.plugins?.allow, fromId, toId);
const deny = replacePluginIdInList(cfg.plugins?.deny, fromId, toId);
let nextPlugins = plugins;
const ensureNextPlugins = () => {
if (nextPlugins === plugins) {
nextPlugins = { ...plugins };
}
return nextPlugins;
};
const nextInstalls = installs ? { ...installs } : undefined;
if (nextInstalls && fromId in nextInstalls) {
const installs = plugins.installs;
if (installs && Object.hasOwn(installs, fromId)) {
const nextInstalls = { ...installs };
const record = nextInstalls[fromId];
if (record && !(toId in nextInstalls)) {
nextInstalls[toId] = record;
}
delete nextInstalls[fromId];
ensureNextPlugins().installs = nextInstalls;
}
const nextEntries = entries ? { ...entries } : undefined;
if (nextEntries && fromId in nextEntries) {
const entries = plugins.entries;
if (entries && Object.hasOwn(entries, fromId)) {
const nextEntries = { ...entries };
const entry = nextEntries[fromId];
if (entry) {
nextEntries[toId] = nextEntries[toId]
@@ -1006,27 +1050,28 @@ function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string
: entry;
}
delete nextEntries[fromId];
ensureNextPlugins().entries = nextEntries;
}
const nextSlots = slots
? {
...slots,
...(slots.memory === fromId ? { memory: toId } : {}),
...(slots.contextEngine === fromId ? { contextEngine: toId } : {}),
}
: undefined;
const allow = replacePluginIdInList(plugins.allow, fromId, toId);
if (allow !== plugins.allow) {
ensureNextPlugins().allow = allow;
}
const deny = replacePluginIdInList(plugins.deny, fromId, toId);
if (deny !== plugins.deny) {
ensureNextPlugins().deny = deny;
}
return {
...cfg,
plugins: {
...cfg.plugins,
allow,
deny,
entries: nextEntries,
installs: nextInstalls,
slots: nextSlots,
},
};
const slots = plugins.slots;
if (slots?.memory === fromId || slots?.contextEngine === fromId) {
ensureNextPlugins().slots = {
...slots,
...(slots.memory === fromId ? { memory: toId } : {}),
...(slots.contextEngine === fromId ? { contextEngine: toId } : {}),
};
}
return nextPlugins === plugins ? cfg : { ...cfg, plugins: nextPlugins };
}
function withoutPluginInstallRecord(cfg: OpenClawConfig, pluginId: string): OpenClawConfig {
@@ -1287,12 +1332,7 @@ export async function updateNpmInstalledPlugins(params: {
}
}
if (
record.source !== "npm" &&
record.source !== "marketplace" &&
record.source !== "clawhub" &&
record.source !== "git"
) {
if (!isPluginInstallRecordUpdateSource(record)) {
outcomes.push({
pluginId,
status: "skipped",