mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06: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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user