* 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>
* fix(plugins): report missing plugin modules as missing, not boundary escapes
The root-scoped open helper returns a classified failure, but five plugin
loader sites collapsed every failure into "escapes plugin root or fails
alias checks". A plugin artifact that is simply absent — e.g. while
dist/extensions/<id> is being re-emitted by a build — was therefore
logged as a containment violation.
Classify the failure instead: missing (ENOENT/ENOTDIR), unreadable
(coded), or an actual boundary/alias rejection. The containment check is
unchanged; only the reported reason is. Also drops the never-supplied
boundaryLabel/boundaryRootDir parameters on loadChannelPluginModule so
one root carries one label.
* test(infra): rename lint-flagged local helper in boundary failure test
* fix(plugins): canonicalize manifest plugin ids to lowercase
Plugin config policy lists (plugins.deny, plugins.allow, plugins.entries)
are lowercase-normalized through normalizePluginId, but a plugin's
self-declared manifest id was only trimmed, never lowercased. A plugin
publishing "id": "Malicious-Scraper" therefore never matched an operator
denylist entry of "malicious-scraper" and fell through to default
activation, loading its hooks, channels, secret integrations, and tools.
The same gap let a mixed-case spelling evade the core reserved-id check.
Canonicalize the id at the manifest parse boundary where
PluginManifestRecord.id is minted, so every downstream policy consumer
compares against the same canonical form.
* test(plugins): prove mixed-case deny at gateway startup
* test(plugins): assert denied gateway record is absent
* test(plugins): materialize allowed secret fixture
* fix(plugins): compare a derived policy key instead of rewriting manifest identity
Manifest ids stay exactly as declared. Deny, allow, and per-entry checks now
compare a lowercase policy key derived at each enforcement boundary, matching
the lowercase-normalized config lists.
The previous approach lowercased PluginManifestRecord.id at the parse boundary.
That id is also matched against the plugin runtime export id, and a mismatch is
a hard load failure, so an existing plugin declaring the same mixed-case id in
both its manifest and its runtime export would stop loading after upgrade.
The Gateway fixture now declares a mixed-case manifest id with a matching
mixed-case runtime export, covering that upgrade case, and drops the allowlist
that previously scoped discovery by a lowercase id and masked the denylist
behavior under test.
* fix(plugins): close mixed-case policy gaps
* test(plugins): use tracked policy fixture temp dir
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>