Files
openclaw/src/plugins/loader-cli-registry.ts
lee-xydt f78d58379a fix(plugins): clone registration record metadata for transactional rollback isolation (#108084)
* 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>
2026-07-28 08:24:48 -04:00

369 lines
13 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { describeRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js";
import { resolveUserPath } from "../utils.js";
import { buildPluginApi } from "./api-builder.js";
import {
resolveEffectiveEnableState,
resolveEffectivePluginActivationState,
resolveMemorySlotDecision,
} from "./config-state.js";
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
import { resolvePluginLoadDiscovery } from "./loader-discovery.js";
import { resolvePluginLoadCacheContext } from "./loader-load-context.js";
import {
createPluginModuleLoader,
formatBundledChannelWrongLoaderError,
resolvePluginModuleExport,
runPluginRegisterSync,
} from "./loader-module-runtime.js";
import {
formatAutoEnabledActivationReason,
formatMissingPluginRegisterError,
markPluginActivationDisabled,
recordPluginError,
} from "./loader-records.js";
import {
applyPluginManifestRecordDetails,
createManifestPluginRecord,
createPluginLoaderLogger,
isAuthorizedDreamingSidecarPlugin,
matchesScopedPluginOrDreamingSidecar,
pushPluginValidationError,
resolveAuthorizedDreamingSidecar,
safeRealpathOrResolve,
validatePluginConfig,
} from "./loader-shared.js";
import type { PluginLoadOptions } from "./loader-types.js";
import { withProfile } from "./plugin-load-profile.js";
import { normalizePluginPolicyId } from "./plugin-policy-id.js";
import { createPluginRegistrationTransaction } from "./plugin-registration-transaction.js";
import { createPluginIdScopeSet } from "./plugin-scope.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import type { PluginRuntime } from "./runtime/types.js";
import { hasKind, kindsEqual } from "./slots.js";
import type { OpenClawPluginModule } from "./types.js";
const CLI_METADATA_ENTRY_BASENAMES = [
"cli-metadata.ts",
"cli-metadata.js",
"cli-metadata.mjs",
"cli-metadata.cjs",
] as const;
export async function loadOpenClawPluginCliRegistry(
options: PluginLoadOptions = {},
): Promise<PluginRegistry> {
const context = resolvePluginLoadCacheContext({ ...options, activate: false });
const logger = options.logger ?? createPluginLoaderLogger();
const onlyPluginIdSet = createPluginIdScopeSet(context.onlyPluginIds);
const loadPluginModule = createPluginModuleLoader({
devSourceRoot: context.devSourceRoot,
pluginSdkResolution: options.pluginSdkResolution,
});
const { registry, registerCli } = createPluginRegistry({
logger,
runtime: {} as PluginRuntime,
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
...(options.coreGatewayMethodNames !== undefined && {
coreGatewayMethodNames: options.coreGatewayMethodNames,
}),
activateGlobalSideEffects: false,
});
const { manifestRegistry, orderedCandidates, manifestBySource } = resolvePluginLoadDiscovery({
options,
context,
diagnostics: registry.diagnostics,
logger,
onlyPluginIdSet,
emitWarning: false,
warningCacheKey: `${context.cacheKey}::cli-metadata`,
});
const seenIds = new Map<string, PluginRecord["origin"]>();
const memorySlot = context.normalized.slots.memory;
let selectedMemoryPluginId: string | null = null;
const dreamingSidecar = resolveAuthorizedDreamingSidecar({
cfg: context.cfg,
normalized: context.normalized,
activationSource: context.activationSource,
manifestRegistry,
memorySlot,
});
for (const candidate of orderedCandidates) {
const manifestRecord = manifestBySource.get(candidate.source);
if (!manifestRecord) {
continue;
}
const pluginId = manifestRecord.id;
const policyId = normalizePluginPolicyId(pluginId);
if (
!matchesScopedPluginOrDreamingSidecar({
onlyPluginIdSet,
pluginId,
sidecar: dreamingSidecar,
})
) {
continue;
}
const isDreamingSidecar = isAuthorizedDreamingSidecarPlugin({
sidecar: dreamingSidecar,
pluginId,
});
const activationState = isDreamingSidecar
? {
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "auto" as const,
reason: `dreaming sidecar for selected memory slot "${dreamingSidecar?.selectedMemoryPluginId ?? ""}"`,
}
: resolveEffectivePluginActivationState({
id: pluginId,
origin: candidate.origin,
config: context.normalized,
rootConfig: context.cfg,
enabledByDefault: isPluginEnabledByDefaultForPlatform(manifestRecord),
activationSource: context.activationSource,
autoEnabledReason: formatAutoEnabledActivationReason(
context.autoEnabledReasons[pluginId],
),
});
const existingOrigin = seenIds.get(pluginId);
if (existingOrigin) {
const duplicate = createManifestPluginRecord({
candidate,
manifestRecord,
enabled: false,
activationState,
});
duplicate.status = "disabled";
duplicate.error = `overridden by ${existingOrigin} plugin`;
markPluginActivationDisabled(duplicate, duplicate.error);
registry.plugins.push(duplicate);
continue;
}
const enableState = isDreamingSidecar
? { enabled: true }
: resolveEffectiveEnableState({
id: pluginId,
origin: candidate.origin,
config: context.normalized,
rootConfig: context.cfg,
enabledByDefault: isPluginEnabledByDefaultForPlatform(manifestRecord),
activationSource: context.activationSource,
});
const entry = context.normalized.entries[policyId];
const record = createManifestPluginRecord({
candidate,
manifestRecord,
enabled: enableState.enabled,
activationState,
});
applyPluginManifestRecordDetails(record, manifestRecord);
const pushPluginLoadError = (message: string) =>
pushPluginValidationError({
registry,
seenIds,
pluginId,
origin: candidate.origin,
record,
message,
});
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
markPluginActivationDisabled(record, enableState.reason);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (record.format === "bundle") {
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (!manifestRecord.configSchema) {
pushPluginLoadError("missing config schema");
continue;
}
const validatedConfig = validatePluginConfig({
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
});
if (!validatedConfig.ok) {
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.error.join(", ")}`);
pushPluginLoadError(`invalid config: ${validatedConfig.error.join(", ")}`);
continue;
}
const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir);
const sourceForCliMetadata =
candidate.origin === "bundled"
? cliMetadataSource
? safeRealpathOrResolve(cliMetadataSource)
: null
: (cliMetadataSource ?? candidate.source);
if (!sourceForCliMetadata) {
record.status = "loaded";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
const opened = openRootFileSync({
absolutePath: sourceForCliMetadata,
rootPath: safeRealpathOrResolve(candidate.rootDir),
boundaryLabel: "plugin root",
rejectHardlinks: shouldRejectHardlinkedPluginFiles({
origin: candidate.origin,
rootDir: candidate.rootDir,
env: context.env,
}),
skipLexicalRootCheck: true,
});
if (!opened.ok) {
pushPluginLoadError(
describeRootFileOpenFailure({
failure: opened,
subject: "plugin entry path",
boundaryLabel: "plugin root",
filePath: sourceForCliMetadata,
}),
);
continue;
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
let mod: OpenClawPluginModule | null;
try {
mod = withProfile(
{ pluginId: record.id, source: safeSource },
"cli-metadata",
() => loadPluginModule(safeSource) as OpenClawPluginModule,
);
} catch (error) {
recordPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
phase: "load",
error,
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
diagnosticMessagePrefix: "failed to load plugin: ",
});
continue;
}
const { definition, register } = resolvePluginModuleExport(mod);
if (definition?.id && definition.id !== record.id) {
pushPluginLoadError(
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
);
continue;
}
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
const manifestKind = record.kind;
const exportKind = definition?.kind;
if (manifestKind && exportKind && !kindsEqual(manifestKind, exportKind)) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `plugin kind mismatch (manifest uses "${String(manifestKind)}", export uses "${String(exportKind)}")`,
});
}
record.kind = definition?.kind ?? record.kind;
if (!isDreamingSidecar) {
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
markPluginActivationDisabled(record, memoryDecision.reason);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
selectedMemoryPluginId = record.id;
record.memorySlotSelected = true;
}
}
if (typeof register !== "function") {
const wrongLoaderError = formatBundledChannelWrongLoaderError(record.kind);
if (wrongLoaderError) {
logger.error(
`[plugins] ${record.id} ${wrongLoaderError}; ensure plugin is loaded via bundled channel discovery, not legacy plugin loader`,
);
pushPluginLoadError(wrongLoaderError);
} else {
logger.error(`[plugins] ${record.id} missing register/activate export`);
pushPluginLoadError(formatMissingPluginRegisterError(mod, context.env));
}
continue;
}
const api = buildPluginApi({
id: record.id,
name: record.name,
version: record.version,
description: record.description,
source: record.source,
rootDir: record.rootDir,
registrationMode: "cli-metadata",
config: context.cfg,
pluginConfig: validatedConfig.value,
runtime: {} as PluginRuntime,
logger,
resolvePath: (input) => resolveUserPath(input),
handlers: {
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
},
});
const transaction = createPluginRegistrationTransaction({ registry, activeRecord: record });
try {
withProfile({ pluginId: record.id, source: record.source }, "cli-metadata:register", () =>
runPluginRegisterSync(register, api),
);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
transaction.commit({ activate: true });
} catch (error) {
transaction.rollback();
recordPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
phase: "register",
error,
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
diagnosticMessagePrefix: "plugin failed during register: ",
});
}
}
return registry;
}
function resolveCliMetadataEntrySource(rootDir: string): string | null {
for (const basename of CLI_METADATA_ENTRY_BASENAMES) {
const candidate = path.join(rootDir, basename);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}