mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 18:35:21 -06:00
f78d58379a
* fix(plugins): deep-clone registry snapshot values for transactional rollback isolation Shallow spread / new Map() copies nested objects by reference, so in-place mutations on PluginRecord fields and Map values inside arrays leak through rollback() — violating transactional isolation. Wrap array items, Map values, and object properties in a recursive deep-clone helper that preserves function references so handlers and resolvers are not lost. Fixes #106647. * fix(plugins): replace generic deep-clone with targeted shallow record cloning Replace the recursive deepCloneRegistryValue with cloneRegistryEntry that shallow-clones registration records to isolate primitive metadata fields while preserving opaque plugin-owned instances (providers, services, channels, harnesses, resolvers) by reference. A generic deep-clone was too broad: it converted every plugin-owned object into a plain object, losing prototypes, internal slots, and shared identity. Add a class-instance regression test proving that providers survive snapshot/rollback with their prototype chain intact and methods callable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(plugins): add curly braces and fix TS type assertions for CI - Add curly braces to single-line if statements in cloneRegistryEntry to satisfy eslint curly rule - Use `as unknown as ProviderPlugin` double cast for test class instance - Call test methods on original variable instead of through registry type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add loader-scenario rollback proof for two sequential plugin transactions (#106647) Simulate the real loader pattern from loader-runtime-candidate L492-531: transaction 1 registers a class-backed provider and commits, transaction 2 mutates registry state and rolls back. Prove the first plugin's metadata is restored and its class-backed provider instance, prototype, and methods survive the rollback. * fix(plugins): snapshot active PluginRecord in registration transactions (#106647) Add activeRecord parameter to createPluginRegistrationTransaction so the active record's array fields (toolNames, hookNames, providerIds, etc.) are snapshotted at transaction creation and restored on rollback. Without this, the loader's recordPluginError path re-pushes the record with stale id arrays from the failed register() call. Also replace flat container copies in snapshotPluginRegistry with cloneRegistryEntry that shallow-clones individual registration records while preserving opaque plugin-owned objects by reference. Update all three production callers (loader-runtime-candidate, loader-channel-runtime, loader-cli-registry) to pass activeRecord. * fix(plugins): snapshot all mutable PluginRecord metadata in transactions (#106647) Expand activeRecord snapshot from array-only to full cloneRegistryEntry so scalars (httpRoutes, hookCount), flags (configSchema, enabled, memorySlotSelected), and Dates are also restored on rollback. Runtime objects (configUiHints, configJsonSchema, contracts) stay by reference. Also update the activeRecord JSDoc to reflect the broader contract. * fix(plugins): restore exact rollback record shape Co-authored-by: 詹幸心0668001037 <zhan.xingxin@xydigit.com> * test(plugins): cover date rollback isolation --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: 詹幸心0668001037 <zhan.xingxin@xydigit.com>
208 lines
7.3 KiB
TypeScript
208 lines
7.3 KiB
TypeScript
// Verifies graceful plugin init failure handling and reporting.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
|
|
|
const fixtureTempDirs: string[] = [];
|
|
const fixtureRoot = makeTrackedTempDir("openclaw-plugin-graceful", fixtureTempDirs);
|
|
let tempDirIndex = 0;
|
|
const { loadOpenClawPlugins, clearPluginLoaderCache } = await import("./loader.test-fixtures.js");
|
|
|
|
afterAll(() => {
|
|
cleanupTrackedTempDirs(fixtureTempDirs);
|
|
});
|
|
|
|
function makeTempDir() {
|
|
const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function writePlugin(params: { id: string; body: string; dir?: string }): {
|
|
id: string;
|
|
file: string;
|
|
dir: string;
|
|
} {
|
|
const dir = params.dir ?? makeTempDir();
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const filename = `${params.id}.cjs`;
|
|
const file = path.join(dir, filename);
|
|
fs.writeFileSync(file, params.body, "utf-8");
|
|
fs.writeFileSync(
|
|
path.join(dir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: params.id,
|
|
name: params.id,
|
|
version: "1.0.0",
|
|
main: filename,
|
|
configSchema: { type: "object" },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
return { id: params.id, file, dir };
|
|
}
|
|
|
|
function readPluginId(pluginPath: string): string {
|
|
const manifestPath = path.join(path.dirname(pluginPath), "openclaw.plugin.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as { id: string };
|
|
return manifest.id;
|
|
}
|
|
|
|
async function loadPlugins(pluginPaths: string[], warnings?: string[]) {
|
|
clearPluginLoaderCache();
|
|
const allow = pluginPaths.map((pluginPath) => readPluginId(pluginPath));
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
enabled: true,
|
|
load: { paths: pluginPaths },
|
|
allow,
|
|
},
|
|
},
|
|
installRecords: {},
|
|
logger: {
|
|
info: () => {},
|
|
debug: () => {},
|
|
error: () => {},
|
|
warn: (message: string) => warnings?.push(message),
|
|
},
|
|
onlyPluginIds: allow,
|
|
workspaceDir: fixtureRoot,
|
|
});
|
|
}
|
|
|
|
type LoadedPluginRegistry = Awaited<ReturnType<typeof loadPlugins>>;
|
|
type LoadedPluginEntry = LoadedPluginRegistry["plugins"][number];
|
|
|
|
function requirePluginEntry(registry: LoadedPluginRegistry, pluginId: string): LoadedPluginEntry {
|
|
const entry = registry.plugins.find((plugin) => plugin.id === pluginId);
|
|
if (!entry) {
|
|
throw new Error(`expected ${pluginId} registry entry`);
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function requireWarning(warnings: string[], text: string): string {
|
|
const warning = warnings.find((candidate) => candidate.includes(text));
|
|
if (!warning) {
|
|
throw new Error(`expected warning containing ${text}`);
|
|
}
|
|
return warning;
|
|
}
|
|
|
|
describe("graceful plugin initialization failure", () => {
|
|
it("marks plugin entry errored when register throws", async () => {
|
|
const plugin = writePlugin({
|
|
id: "throws-on-register",
|
|
body: `module.exports = { id: "throws-on-register", register() { throw new Error("config schema mismatch"); } };`,
|
|
});
|
|
|
|
const registry = await loadPlugins([plugin.file]);
|
|
expect(requirePluginEntry(registry, "throws-on-register").status).toBe("error");
|
|
});
|
|
|
|
it("keeps loading other plugins after one register failure", async () => {
|
|
const failing = writePlugin({
|
|
id: "plugin-fail",
|
|
body: `module.exports = { id: "plugin-fail", register() { throw new Error("boom"); } };`,
|
|
});
|
|
const working = writePlugin({
|
|
id: "plugin-ok",
|
|
body: `module.exports = { id: "plugin-ok", register() {} };`,
|
|
});
|
|
|
|
const registry = await loadPlugins([failing.file, working.file]);
|
|
|
|
expect(registry.plugins.find((plugin) => plugin.id === "plugin-ok")?.status).toBe("loaded");
|
|
});
|
|
|
|
it("records failed register metadata", async () => {
|
|
const plugin = writePlugin({
|
|
id: "register-error",
|
|
body: `module.exports = { id: "register-error", register() { throw new Error("brutal config fail"); } };`,
|
|
});
|
|
|
|
const before = new Date();
|
|
const registry = await loadPlugins([plugin.file]);
|
|
const after = new Date();
|
|
|
|
const failed = requirePluginEntry(registry, "register-error");
|
|
expect(failed.status).toBe("error");
|
|
expect(failed.failurePhase).toBe("register");
|
|
expect(failed.error).toContain("brutal config fail");
|
|
expect(failed.failedAt).toBeInstanceOf(Date);
|
|
expect(failed.failedAt?.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
|
expect(failed.failedAt?.getTime()).toBeLessThanOrEqual(after.getTime());
|
|
});
|
|
|
|
it("rolls back partial metadata without breaking an earlier class-backed service", async () => {
|
|
const stable = writePlugin({
|
|
id: "a-stable-service-plugin",
|
|
body: `class StableService {
|
|
constructor() { this.id = "stable-service"; }
|
|
start() {}
|
|
ping() { return "still-alive"; }
|
|
}
|
|
module.exports = { id: "a-stable-service-plugin", register(api) {
|
|
api.registerService(new StableService());
|
|
} };`,
|
|
});
|
|
const failing = writePlugin({
|
|
id: "z-partial-register-failure",
|
|
body: `module.exports = { id: "z-partial-register-failure", register(api) {
|
|
api.registerService({ id: "failed-service", start() {} });
|
|
api.registerHttpRoute({ path: "/failed", auth: "plugin", handler: async () => true });
|
|
throw new Error("fail after partial registration");
|
|
} };`,
|
|
});
|
|
|
|
const registry = await loadPlugins([stable.file, failing.file]);
|
|
const failed = requirePluginEntry(registry, "z-partial-register-failure");
|
|
const stableService = registry.services.find((entry) => entry.service.id === "stable-service")
|
|
?.service as { ping?: () => string } | undefined;
|
|
|
|
expect(failed.status).toBe("error");
|
|
expect(failed.services).toEqual([]);
|
|
expect(failed.httpRoutes).toBe(0);
|
|
expect(registry.services.map((entry) => entry.service.id)).toEqual(["stable-service"]);
|
|
expect(registry.httpRoutes).toEqual([]);
|
|
expect(stableService?.ping?.()).toBe("still-alive");
|
|
});
|
|
|
|
it("records validation failures before register", async () => {
|
|
const plugin = writePlugin({
|
|
id: "missing-register",
|
|
body: `module.exports = { id: "missing-register" };`,
|
|
});
|
|
|
|
const registry = await loadPlugins([plugin.file]);
|
|
const failed = registry.plugins.find((entry) => entry.id === "missing-register");
|
|
|
|
expect(failed?.status).toBe("error");
|
|
expect(failed?.failurePhase).toBe("validation");
|
|
expect(failed?.error).toBe("plugin export missing register/activate");
|
|
});
|
|
|
|
it("logs a startup summary grouped by failure phase", async () => {
|
|
const registerFailure = writePlugin({
|
|
id: "warn-register",
|
|
body: `module.exports = { id: "warn-register", register() { throw new Error("bad config"); } };`,
|
|
});
|
|
const validationFailure = writePlugin({
|
|
id: "warn-validation",
|
|
body: `module.exports = { id: "warn-validation" };`,
|
|
});
|
|
|
|
const warnings: string[] = [];
|
|
await loadPlugins([registerFailure.file, validationFailure.file], warnings);
|
|
|
|
const summary = requireWarning(warnings, "failed to initialize");
|
|
expect(summary).toContain("register: warn-register");
|
|
expect(summary).toContain("validation: warn-validation");
|
|
expect(summary).toContain("openclaw plugins inspect <id> --runtime --json");
|
|
expect(summary).toContain("openclaw plugins list");
|
|
});
|
|
});
|