fix(plugins): repair missing payloads and Docker reinstall checks (#136806)

* fix(plugins): rediscover restored payloads during update

Update reconciliation reused cached filesystem facts across package
installation. A repaired missing payload still appeared absent, causing
strict package-owner resolution to reject its runtime child list; replaced
metadata could likewise retain retired children.

Give cohort snapshots and post-install CLI reconciliation fresh operation
caches. Preserve the authoritative owner check and retained runtime cache
generations instead of invalidating a running gateway or adding fallbacks.

Proof: missing/replaced real-filesystem regressions fail before this change;
67 focused cohort, package-policy, and plugin CLI tests pass. The original
2026.9.1 package fails the plugin-update Docker lane at missing-payload-accepted;
the same release with this repair passes consent, repair, and unchanged checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(test): enable reinstalled npm plugins before CLI proof

The runtime-plugin sweep invoked demo-npm after uninstall --keep-files and
reinstall. Reinstall intentionally retains the explicit disabled marker,
so command registration was correctly absent on that second invocation.

Assert the reinstalled disabled state, then explicitly enable the fixture
through the existing capability-consent helper before checking its CLI.
Keep command registration and operator uninstall intent unchanged.

Proof: the full Docker plugins lane fails before and passes after this
harness change using the identical 2026.9.1 package and registry snapshot.
The stateful sweep regression rejects command execution until re-enabled;
all 23 package-consent compatibility tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Peter Steinberger 2026-09-02 21:19:29 -07:00 • committed by GitHub
parent 2fe4f83a96
commit b812fb7b7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 160 additions and 22 deletions

View file

@ -786,6 +786,18 @@ function assertNpmPluginRetained() {
}
}
function assertNpmPluginReinstalled() {
if (process.env.OPENCLAW_ALLOW_FROZEN_TARGET_SCENARIO_OMISSIONS === "1") {
return;
}
assertPluginUninstallConfigState(readOpenClawConfig(), "demo-plugin-npm");
const list = readJson(scratchFile("plugins-npm-reinstalled.json"));
const plugin = list.plugins?.find((entry) => entry.id === "demo-plugin-npm");
if (plugin?.enabled !== false || plugin.status !== "disabled") {
throw new Error("reinstalled npm plugin must remain disabled until explicitly enabled");
}
}
function assertInvalidOpenClawExtensionsRejected() {
const pluginId = "demo-plugin-invalid-metadata";
for (const expected of ["openclaw.extensions[1]", "non-empty string"]) {
@ -1061,6 +1073,7 @@ const commands = {
"plugin-npm": assertNpmPlugin,
"plugin-npm-update": assertNpmPluginUpdateUnchanged,
"plugin-npm-retained": assertNpmPluginRetained,
"plugin-npm-reinstalled": assertNpmPluginReinstalled,
"plugin-npm-removed": assertNpmPluginRemoved,
"invalid-openclaw-extensions": assertInvalidOpenClawExtensionsRejected,
"bundle-disabled": assertClaudeBundleDisabled,

View file

@ -240,6 +240,10 @@ run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-retained.jso
node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-retained
run_plugins_fixture_logged reinstall-npm plugins install "npm:@openclaw/demo-plugin-npm@0.0.1" --force
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-reinstalled.json" plugins list --json
node scripts/e2e/lib/plugins/assertions.mjs plugin-npm-reinstalled
# Reinstall preserves the explicit uninstall marker until the operator enables the plugin.
run_plugins_fixture_logged enable-reinstalled-npm plugins enable demo-plugin-npm
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm.json" plugins list --json
run_plugins_openclaw_capture "$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-inspect.json" plugins inspect demo-plugin-npm --runtime --json
run_plugins_shell_logged exec-reinstalled-npm-plugin-cli 'node "$OPENCLAW_ENTRY" demo-npm ping >"$OPENCLAW_PLUGINS_TMP_DIR/plugins-npm-cli.txt"'

View file

@ -43,6 +43,7 @@ import {
import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import { resolveInstalledPluginLifecycleOwnership } from "../plugins/installed-plugin-package-ownership.js";
import { configReferencesNpmInstallPath } from "../plugins/installs.js";
import { createPluginCache, withPluginCache } from "../plugins/plugin-cache.js";
import {
withPluginLifecycleLease,
type PluginLifecycleLeaseContext,
@ -419,7 +420,7 @@ async function runPluginUpdateCommandUnlocked(
allowPrompt: !params.opts.dryRun,
});
const deferredInstallTransactions: PluginInstallTransaction[] = [];
let pluginResult;
let pluginResult: Awaited<ReturnType<typeof updateNpmInstalledPlugins>>;
try {
pluginResult =
pluginSelection.pluginIds.length > 0
@ -471,10 +472,13 @@ async function runPluginUpdateCommandUnlocked(
try {
if (pluginSelection.pluginIds.length > 0 && pluginResult.changed && !params.opts.dryRun) {
const nextInstallRecords = pluginResult.config.plugins?.installs ?? {};
const afterIndex = loadInstalledPluginIndex({
config: pluginResult.config,
installRecords: nextInstallRecords,
});
// The installer may restore or replace bytes at a previously observed path.
const afterIndex = withPluginCache(createPluginCache(), () =>
loadInstalledPluginIndex({
config: pluginResult.config,
installRecords: nextInstallRecords,
}),
);
const reconciled = reconcilePluginPackageUpdateConfig({
config: pluginResult.config,
beforeIndex: installedPluginIndex,

View file

@ -1,9 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { attachPluginInstallOwnerMigrations } from "./install-transaction.js";
import { recordInstalledPluginIndexInstallOwner } from "./installed-plugin-index-install-owner.js";
import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import { createPluginCache, withPluginCache } from "./plugin-cache.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const syncPluginsForUpdateChannelMock = vi.fn();
const updateNpmInstalledPluginsMock = vi.fn();
@ -76,8 +80,10 @@ function installedIndex(params: {
}
describe("plugin release cohort package reconciliation", () => {
const tempDirs: string[] = [];
afterEach(() => cleanupTrackedTempDirs(tempDirs));
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
collectMissingPluginInstallPayloadsMock.mockResolvedValue([]);
syncPluginsForUpdateChannelMock.mockImplementation(async ({ config }) => ({
config,
@ -92,6 +98,87 @@ describe("plugin release cohort package reconciliation", () => {
}));
});
it.each(["missing", "replaced"] as const)(
"reconciles %s payloads against the new package metadata",
async (state) => {
const { loadInstalledPluginIndex } = await vi.importActual<
typeof import("./installed-plugin-index.js")
>("./installed-plugin-index.js");
loadInstalledPluginIndexMock.mockImplementation(loadInstalledPluginIndex);
const root = fs.realpathSync(makeTrackedTempDir("openclaw-cohort", tempDirs));
const installPath = path.join(root, "package");
const env = {
...process.env,
HOME: root,
OPENCLAW_STATE_DIR: path.join(root, "state"),
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "bundled"),
};
const writePayload = (pluginId: string) => {
fs.mkdirSync(installPath, { recursive: true });
fs.writeFileSync(
path.join(installPath, "package.json"),
JSON.stringify({
name: "@example/cohort",
version: "1.0.0",
openclaw: { extensions: ["./index.js"] },
}),
);
fs.writeFileSync(
path.join(installPath, "openclaw.plugin.json"),
JSON.stringify({ id: pluginId, configSchema: { type: "object" } }),
);
fs.writeFileSync(path.join(installPath, "index.js"), "module.exports = {};\n");
};
if (state === "replaced") {
writePayload("retired-child");
}
const records = {
cohort: { source: "npm", spec: "@example/cohort", installPath },
} satisfies Record<string, PluginInstallRecord>;
const config = {
plugins: {
installs: records,
entries: { "retired-child": { enabled: true }, unrelated: { enabled: false } },
},
} satisfies OpenClawConfig;
if (state === "missing") {
collectMissingPluginInstallPayloadsMock.mockResolvedValueOnce([
{ pluginId: "cohort", installPath, reason: "missing-package-dir" },
]);
}
updateNpmInstalledPluginsMock.mockImplementation(async ({ config: current }) => {
writePayload("current-child");
return {
config: {
...current,
plugins: {
...current.plugins,
installs: { cohort: { ...records.cohort, version: "1.0.0" } },
},
},
changed: true,
outcomes: [],
};
});
const result = await withPluginCache(createPluginCache(), () =>
convergePluginReleaseCohort({
config,
installRecords: records,
channel: "stable",
timeoutMs: 60_000,
env,
}),
);
expect(result.config.plugins?.entries?.unrelated).toEqual({ enabled: false });
if (state === "replaced") {
expect(result.config.plugins?.entries).not.toHaveProperty("retired-child");
}
expect(result.config.plugins?.installs?.cohort?.version).toBe("1.0.0");
},
);
it("removes the legacy load path after a successful post-core owner migration", async () => {
const legacyRoot = "/plugins/qqbot-legacy";
const canonicalRoot = "/plugins/openclaw-qqbot";

View file

@ -9,6 +9,7 @@ import {
collectMissingPluginInstallPayloads,
type MissingPluginInstallPayload,
} from "./payload-verification.js";
import { createPluginCache, withPluginCache } from "./plugin-cache.js";
import {
capturePluginPackageUpdateSnapshot,
reconcilePluginPackageUpdateConfig,
@ -60,12 +61,14 @@ export async function convergePluginReleaseCohort(params: {
let config = sync.config;
let changed = sync.changed;
let npmChanged = false;
const beforeIndex = loadInstalledPluginIndex({
config,
installRecords: config.plugins?.installs ?? {},
workspaceDir: params.workspaceDir,
env: params.env,
});
const beforeIndex = withPluginCache(createPluginCache(), () =>
loadInstalledPluginIndex({
config,
installRecords: config.plugins?.installs ?? {},
workspaceDir: params.workspaceDir,
env: params.env,
}),
);
const packageUpdateSnapshot = capturePluginPackageUpdateSnapshot({
index: beforeIndex,
installOwners: Object.keys(config.plugins?.installs ?? {}),
@ -127,12 +130,16 @@ export async function convergePluginReleaseCohort(params: {
npmChanged ||= update.changed;
Object.assign(installOwnerMigrations, resolvePluginInstallOwnerMigrations(update));
const afterIndex = loadInstalledPluginIndex({
config,
installRecords: config.plugins?.installs ?? {},
workspaceDir: params.workspaceDir,
env: params.env,
});
// Reinstall can restore the same path. Reconciliation needs new filesystem facts,
// including formerly missing files, without retiring a retained runtime generation.
const afterIndex = withPluginCache(createPluginCache(), () =>
loadInstalledPluginIndex({
config,
installRecords: config.plugins?.installs ?? {},
workspaceDir: params.workspaceDir,
env: params.env,
}),
);
const reconciled = reconcilePluginPackageUpdateConfig({
config,
beforeIndex,

View file

@ -11,7 +11,13 @@ const BASH_BIN = process.platform === "win32" ? "bash" : "/bin/bash";
function writeCandidate(
root: string,
options: { commands?: string[]; helpStatus?: number; hang?: boolean; exitCode?: number } = {},
options: {
commands?: string[];
helpStatus?: number;
hang?: boolean;
exitCode?: number;
preserveUninstallIntent?: boolean;
} = {},
name = "candidate.cjs",
) {
const entry = path.join(root, name);
@ -29,6 +35,17 @@ if (args.includes('--help')) {
if (options.hang) setInterval(() => {}, 1000);
else process.exit(options.helpStatus ?? 0);
} else {
if (options.preserveUninstallIntent) {
const marker = process.env.ARGV_LOG + '.disabled';
if (args[0] === 'plugins' && args[2] === 'demo-plugin-npm') {
if (args[1] === 'uninstall') fs.writeFileSync(marker, 'disabled');
if (args[1] === 'enable') fs.rmSync(marker, { force: true });
}
if (args[0] === 'demo-npm' && fs.existsSync(marker)) {
console.error('OpenClaw does not know the command "demo-npm".');
process.exit(43);
}
}
const accepted = args.includes('--accept-capabilities');
if (accepted && !supported) {
console.error('unknown option --accept-capabilities');
@ -140,7 +157,10 @@ describe("package fixture consent compatibility", () => {
"runs the plugin sweep with selective consent (supported=%s)",
(supported) => {
const root = tempDirs.make("openclaw-consent-sweep-");
const entry = writeCandidate(root, { commands: supported ? undefined : [] });
const entry = writeCandidate(root, {
commands: supported ? undefined : [],
preserveUninstallIntent: true,
});
const result = runShell(root, entry, sweepFixtureLoader);
expect(result.status, result.stderr).toBe(0);
const mutations = result.calls.filter(
@ -158,7 +178,10 @@ describe("package fixture consent compatibility", () => {
expect(args.includes(consent), args.join(" ")).toBe(supported && positive);
}
expect(mutations.filter((args) => args[1] === "update")).toHaveLength(5);
expect(mutations.find((args) => args[1] === "enable")).toContain("claude-bundle-e2e");
expect(mutations.filter((args) => args[1] === "enable").map((args) => args[2])).toEqual([
"demo-plugin-npm",
"claude-bundle-e2e",
]);
},
);