mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-04 06:35:52 +00:00
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:
parent
2fe4f83a96
commit
b812fb7b7b
6 changed files with 160 additions and 22 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue