From 7c1d0785c42803b30be7a984e88524dee9e01006 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 15:01:06 +0000 Subject: [PATCH] Plugins: share loader provenance helpers --- src/extension-host/cutover-inventory.md | 100 +++++++++--------- .../loader-finalization-policy.ts | 53 ++-------- src/extension-host/loader-orchestrator.ts | 2 +- src/extension-host/loader-policy.ts | 87 +++------------ src/extension-host/loader-provenance.test.ts | 88 +++++++++++++++ src/extension-host/loader-provenance.ts | 98 +++++++++++++++++ 6 files changed, 258 insertions(+), 170 deletions(-) create mode 100644 src/extension-host/loader-provenance.test.ts create mode 100644 src/extension-host/loader-provenance.ts diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index 0c10492389e..d541f6ce053 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -25,56 +25,56 @@ This is an implementation checklist, not a future-design spec. ## Current Inventory -| Surface | Current implementation | Target owner | Status | How it has been handled so far | -| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Active runtime registry state | `src/plugins/runtime.ts` plus global plugin runtime state | `src/extension-host/active-registry.ts` | `moved` | Host-owned active registry exists; `src/plugins/runtime.ts` is now a compatibility facade. | -| Normalized extension descriptor model | plugin manifests and package metadata interpreted ad hoc across `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` | `partial` | `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` exist; current manifests project into them through compatibility adapters. | -| Resolved static registry | flat rows in `src/plugins/manifest-registry.ts` | `src/extension-host/resolved-registry.ts` | `partial` | Manifest records now carry `resolvedExtension`; a host-owned resolved registry view exists for static consumers. | -| Manifest/package metadata loading | `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` and `src/extension-host/manifest-registry.ts` | `partial` | Package metadata parsing is routed through host schema helpers; legacy loader flow still supplies the source manifests. | -| Loader SDK alias compatibility | `src/plugins/loader.ts` | `src/extension-host/loader-compat.ts` | `partial` | Plugin-SDK alias candidate ordering, alias-file resolution, and scoped alias-map construction now live in host-owned loader compatibility helpers. | -| Loader cache key and registry cache control | `src/plugins/loader.ts` | `src/extension-host/loader-cache.ts` | `partial` | Cache-key construction, LRU registry cache reads and writes, and cache clearing now delegate through host-owned loader-cache helpers while preserving the current cache shape and cap. | -| Loader provenance and duplicate-order policy | `src/plugins/loader.ts` | `src/extension-host/loader-policy.ts` | `partial` | Plugin-record creation, duplicate precedence, and provenance indexing now live in host-owned loader-policy helpers. | -| Loader discovery policy results | mixed inside `src/plugins/loader.ts`, `src/extension-host/loader-policy.ts`, and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-discovery-policy.ts` | `partial` | Open-allowlist discovery warnings now resolve through explicit host-owned discovery-policy results before the orchestrator logs them. | -| Loader initial candidate planning and record creation | `src/plugins/loader.ts` | `src/extension-host/loader-records.ts` | `partial` | Duplicate detection, initial record creation, manifest metadata attachment, and first-pass enable-state planning now delegate through host-owned loader-records helpers. | -| Loader entry-path opening and module import | `src/plugins/loader.ts` | `src/extension-host/loader-import.ts` | `partial` | Boundary-checked entry opening and module import now delegate through host-owned loader-import helpers while preserving the current trusted in-process loading model. | -| Loader module-export, config-validation, and memory-slot decisions | `src/plugins/loader.ts` | `src/extension-host/loader-runtime.ts` | `partial` | Module export resolution, export-metadata application, config validation, and early or final memory-slot decisions now delegate through host-owned loader-runtime helpers. | -| Loader post-import planning and register execution | `src/plugins/loader.ts` | `src/extension-host/loader-register.ts` | `partial` | Definition application, post-import validation planning, and `register(...)` execution now delegate through host-owned loader-register helpers while preserving current plugin behavior. | -| Loader per-candidate orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-flow.ts` | `partial` | The per-candidate load flow now runs through a host-owned orchestrator that composes planning, import, runtime validation, register execution, and record-state helpers. | -| Loader top-level load orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-orchestrator.ts` | `partial` | Cache hits, runtime creation, discovery, manifest loading, candidate ordering, candidate processing, and finalization now route through a host-owned loader orchestrator while `src/plugins/loader.ts` remains the compatibility facade. | -| Loader mutable activation state session | local variables in `src/plugins/loader.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-session.ts` | `partial` | Seen-id tracking, memory-slot selection state, and finalization inputs now live in a host-owned loader session instead of being spread across top-level loader variables. | -| Loader activation policy outcomes | open-coded in `src/plugins/loader.ts` and `src/extension-host/loader-flow.ts` | `src/extension-host/loader-activation-policy.ts` | `partial` | Duplicate precedence, config enablement, and early memory-slot gating now resolve through explicit host-owned activation-policy outcomes instead of remaining as inline loader decisions. | -| Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | The loader now enforces an explicit lifecycle transition model (`prepared -> imported -> validated -> registered -> ready`, plus terminal `disabled` and `error`) while still mapping back to compatibility `PluginRecord.status` values. | -| Loader finalization policy results | mixed inside `src/plugins/loader.ts`, `src/extension-host/loader-policy.ts`, and `src/extension-host/loader-finalize.ts` | `src/extension-host/loader-finalization-policy.ts` | `partial` | Memory-slot finalization warnings and provenance-based untracked-extension warnings now resolve through explicit host-owned finalization-policy results before the finalizer applies them. | -| Loader final cache, warning, and activation finalization | `src/plugins/loader.ts` | `src/extension-host/loader-finalize.ts` | `partial` | Cache writes, untracked-extension warnings, final memory-slot warnings, readiness promotion, and registry activation now delegate through a host-owned loader-finalize helper; broader host lifecycle and policy semantics are still pending. | -| Channel lookup | `src/channels/plugins/index.ts`, `src/channels/plugins/registry-loader.ts`, `src/channels/registry.ts` | extension-host-backed registries plus kernel channel contracts | `partial` | Readers now consume the host-owned active registry, but writes still originate from plugin registration. | -| Dock lookup | `src/channels/dock.ts` | host-owned static descriptors | `partial` | Runtime lookup now uses the host boundary; dock ownership itself has not moved yet. | -| Message-channel normalization | `src/utils/message-channel.ts` | host-owned channel registry view | `partial` | Lookup path now reads through the host-owned active registry. | -| Default plugin HTTP route lookup | `src/plugins/http-registry.ts` | host-owned route registry | `partial` | Default registry resolution now uses the host boundary; route registration compatibility still flows through the legacy plugin API. | -| Channel catalog static metadata | `src/channels/plugins/catalog.ts` | host-owned static descriptors | `partial` | Package metadata parsing now flows through host schema helpers; full canonical catalog migration has not started. | -| Plugin skill discovery | `src/agents/skills/plugin-skills.ts` | host-owned resolved registry | `moved` | Static consumer now reads only resolved-extension data for skill paths and enablement filtering. | -| Plugin auto-enable | `src/config/plugin-auto-enable.ts` | host-owned resolved registry | `partial` | Primary logic runs on resolved-extension data; old manifest-registry injection remains as a compatibility input for older callers and tests. | -| Config validation indexing | `src/config/validation.ts`, `src/config/resolved-extension-validation.ts` | host-owned resolved registry | `moved` | Validation indexing now builds from resolved-extension records instead of flat manifest rows. | -| Config doc baseline generation | `src/config/doc-baseline.ts` | host-owned resolved registry | `moved` | Bundled plugin and channel metadata now load through the resolved-extension registry. | -| Plugin loader activation | `src/plugins/loader.ts` | extension host lifecycle + compatibility loader | `partial` | Activation now routes through `src/extension-host/activation.ts`, but discovery, enablement, provenance, module loading, and policy still live in the legacy plugin loader. | -| Channel registration writes | `src/plugins/registry.ts` | host-owned channel registry | `partial` | Validation and normalization now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. | -| Provider registration writes | `src/plugins/registry.ts` | host-owned provider registry | `partial` | Provider normalization still happens in plugin-era validation, but duplicate detection and normalized registration shape now delegate to `src/extension-host/runtime-registrations.ts`. | -| HTTP route registration writes | `src/plugins/registry.ts` | host-owned route registry | `partial` | Route validation and normalization now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. | -| Gateway method registration writes | `src/plugins/registry.ts` | host-owned runtime contribution registry | `partial` | Duplicate detection and normalized method registration now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. | -| Tool registration writes | `src/plugins/registry.ts` | host-owned tool registry | `partial` | Tool-name normalization and tool-factory shaping now delegate to `src/extension-host/runtime-registrations.ts`, but duplicate handling still follows the legacy tool path. | -| CLI registration writes | `src/plugins/registry.ts` | host-owned CLI registry | `partial` | CLI command-name normalization now delegates to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. | -| Service registration writes | `src/plugins/registry.ts` | host-owned service registry | `partial` | Service-id normalization now delegates to `src/extension-host/runtime-registrations.ts`, but lifecycle remains legacy-owned. | -| Command registration writes | `src/plugins/registry.ts` | host-owned command registry | `partial` | Command-name normalization now delegates to `src/extension-host/runtime-registrations.ts`, but duplicate enforcement still depends on the legacy plugin command registry. | -| Context-engine registration writes | `src/plugins/registry.ts` | host-owned context-engine registry | `partial` | Context-engine id normalization now delegates to `src/extension-host/runtime-registrations.ts`, but the actual context-engine registry remains legacy-owned. | -| Legacy hook registration writes | `src/plugins/registry.ts` | host-owned hook registry | `partial` | Hook-entry construction and event normalization now delegate to `src/extension-host/runtime-registrations.ts`, but internal-hook bridging still remains in the legacy plugin registry. | -| Typed-hook registration writes | `src/plugins/registry.ts` | host-owned typed-hook registry | `partial` | Typed-hook record construction and hook-name validation now delegate to `src/extension-host/runtime-registrations.ts`, but prompt-injection policy and execution semantics remain legacy-owned. | -| Hook execution and global runner | `src/plugins/hook-runner-global.ts`, `src/hooks/internal-hooks.ts`, plugin hook registration in `src/plugins/registry.ts` | canonical kernel event stages + host bridges | `not started` | No canonical event-stage migration has landed yet. | -| Service lifecycle | `src/plugins/services.ts` and plugin service registration | extension host lifecycle | `not started` | Service startup and teardown still depend on legacy plugin registry/service ownership. | -| CLI registration | plugin CLI registration in `src/plugins/registry.ts` and CLI loaders | extension host registry + static descriptors where possible | `not started` | No host-owned CLI registry exists yet. | -| Gateway/server methods | `src/plugins/registry.ts` gateway handler registration | host-owned runtime contribution registry | `not started` | Still registered directly into the legacy plugin registry. | -| Slot arbitration | `src/plugins/slots.ts` | host-owned arbitration model | `not started` | Current slot selection remains plugin-era logic. | -| ACP backend registry | `src/acp/runtime/registry.ts` | host-owned runtime-backend registry | `not started` | ACP backends still mutate a global ACP runtime registry directly. | -| Onboarding/install/setup surfaces | `src/plugins/install.ts`, package manifests, channel catalog, onboarding commands | host-owned static descriptors | `partial` | Static metadata normalization has started; full setup/install descriptor migration is not done. | -| Pilot migrations | `extensions/thread-ownership`, `extensions/telegram`, `extensions/acpx` | extension-host path with parity tracking | `not started` | No pilot runs through the host path yet. | +| Surface | Current implementation | Target owner | Status | How it has been handled so far | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Active runtime registry state | `src/plugins/runtime.ts` plus global plugin runtime state | `src/extension-host/active-registry.ts` | `moved` | Host-owned active registry exists; `src/plugins/runtime.ts` is now a compatibility facade. | +| Normalized extension descriptor model | plugin manifests and package metadata interpreted ad hoc across `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` | `partial` | `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` exist; current manifests project into them through compatibility adapters. | +| Resolved static registry | flat rows in `src/plugins/manifest-registry.ts` | `src/extension-host/resolved-registry.ts` | `partial` | Manifest records now carry `resolvedExtension`; a host-owned resolved registry view exists for static consumers. | +| Manifest/package metadata loading | `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` and `src/extension-host/manifest-registry.ts` | `partial` | Package metadata parsing is routed through host schema helpers; legacy loader flow still supplies the source manifests. | +| Loader SDK alias compatibility | `src/plugins/loader.ts` | `src/extension-host/loader-compat.ts` | `partial` | Plugin-SDK alias candidate ordering, alias-file resolution, and scoped alias-map construction now live in host-owned loader compatibility helpers. | +| Loader cache key and registry cache control | `src/plugins/loader.ts` | `src/extension-host/loader-cache.ts` | `partial` | Cache-key construction, LRU registry cache reads and writes, and cache clearing now delegate through host-owned loader-cache helpers while preserving the current cache shape and cap. | +| Loader provenance and duplicate-order policy | `src/plugins/loader.ts` | `src/extension-host/loader-policy.ts` | `partial` | Plugin-record creation, duplicate precedence, and provenance indexing now live in host-owned loader-policy helpers. | +| Loader discovery policy results | mixed inside `src/extension-host/loader-policy.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-discovery-policy.ts` | `partial` | Open-allowlist discovery warnings now resolve through explicit host-owned discovery-policy results before the orchestrator logs them. | +| Loader initial candidate planning and record creation | `src/plugins/loader.ts` | `src/extension-host/loader-records.ts` | `partial` | Duplicate detection, initial record creation, manifest metadata attachment, and first-pass enable-state planning now delegate through host-owned loader-records helpers. | +| Loader entry-path opening and module import | `src/plugins/loader.ts` | `src/extension-host/loader-import.ts` | `partial` | Boundary-checked entry opening and module import now delegate through host-owned loader-import helpers while preserving the current trusted in-process loading model. | +| Loader module-export, config-validation, and memory-slot decisions | `src/plugins/loader.ts` | `src/extension-host/loader-runtime.ts` | `partial` | Module export resolution, export-metadata application, config validation, and early or final memory-slot decisions now delegate through host-owned loader-runtime helpers. | +| Loader post-import planning and register execution | `src/plugins/loader.ts` | `src/extension-host/loader-register.ts` | `partial` | Definition application, post-import validation planning, and `register(...)` execution now delegate through host-owned loader-register helpers while preserving current plugin behavior. | +| Loader per-candidate orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-flow.ts` | `partial` | The per-candidate load flow now runs through a host-owned orchestrator that composes planning, import, runtime validation, register execution, and record-state helpers. | +| Loader top-level load orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-orchestrator.ts` | `partial` | Cache hits, runtime creation, discovery, manifest loading, candidate ordering, candidate processing, and finalization now route through a host-owned loader orchestrator while `src/plugins/loader.ts` remains the compatibility facade. | +| Loader mutable activation state session | local variables in `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-session.ts` | `partial` | Seen-id tracking, memory-slot selection state, and finalization inputs now live in a host-owned loader session instead of being spread across top-level loader variables. | +| Loader activation policy outcomes | open-coded in `src/extension-host/loader-flow.ts` | `src/extension-host/loader-activation-policy.ts` | `partial` | Duplicate precedence, config enablement, and early memory-slot gating now resolve through explicit host-owned activation-policy outcomes instead of remaining as inline loader decisions. | +| Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | The loader now enforces an explicit lifecycle transition model (`prepared -> imported -> validated -> registered -> ready`, plus terminal `disabled` and `error`) while still mapping back to compatibility `PluginRecord.status` values. | +| Loader finalization policy results | mixed inside `src/extension-host/loader-policy.ts` and `src/extension-host/loader-finalize.ts` | `src/extension-host/loader-finalization-policy.ts` | `partial` | Memory-slot finalization warnings and provenance-based untracked-extension warnings now resolve through explicit host-owned finalization-policy results before the finalizer applies them. | +| Loader final cache, readiness, and activation finalization | `src/plugins/loader.ts` | `src/extension-host/loader-finalize.ts` | `partial` | Cache writes, readiness promotion, and registry activation now delegate through a host-owned loader-finalize helper; broader host lifecycle and policy semantics are still pending. | +| Channel lookup | `src/channels/plugins/index.ts`, `src/channels/plugins/registry-loader.ts`, `src/channels/registry.ts` | extension-host-backed registries plus kernel channel contracts | `partial` | Readers now consume the host-owned active registry, but writes still originate from plugin registration. | +| Dock lookup | `src/channels/dock.ts` | host-owned static descriptors | `partial` | Runtime lookup now uses the host boundary; dock ownership itself has not moved yet. | +| Message-channel normalization | `src/utils/message-channel.ts` | host-owned channel registry view | `partial` | Lookup path now reads through the host-owned active registry. | +| Default plugin HTTP route lookup | `src/plugins/http-registry.ts` | host-owned route registry | `partial` | Default registry resolution now uses the host boundary; route registration compatibility still flows through the legacy plugin API. | +| Channel catalog static metadata | `src/channels/plugins/catalog.ts` | host-owned static descriptors | `partial` | Package metadata parsing now flows through host schema helpers; full canonical catalog migration has not started. | +| Plugin skill discovery | `src/agents/skills/plugin-skills.ts` | host-owned resolved registry | `moved` | Static consumer now reads only resolved-extension data for skill paths and enablement filtering. | +| Plugin auto-enable | `src/config/plugin-auto-enable.ts` | host-owned resolved registry | `partial` | Primary logic runs on resolved-extension data; old manifest-registry injection remains as a compatibility input for older callers and tests. | +| Config validation indexing | `src/config/validation.ts`, `src/config/resolved-extension-validation.ts` | host-owned resolved registry | `moved` | Validation indexing now builds from resolved-extension records instead of flat manifest rows. | +| Config doc baseline generation | `src/config/doc-baseline.ts` | host-owned resolved registry | `moved` | Bundled plugin and channel metadata now load through the resolved-extension registry. | +| Plugin loader activation | `src/plugins/loader.ts` | extension host lifecycle + compatibility loader | `partial` | Activation now routes through `src/extension-host/activation.ts`, but discovery, enablement, provenance, module loading, and policy still live in the legacy plugin loader. | +| Channel registration writes | `src/plugins/registry.ts` | host-owned channel registry | `partial` | Validation and normalization now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. | +| Provider registration writes | `src/plugins/registry.ts` | host-owned provider registry | `partial` | Provider normalization still happens in plugin-era validation, but duplicate detection and normalized registration shape now delegate to `src/extension-host/runtime-registrations.ts`. | +| HTTP route registration writes | `src/plugins/registry.ts` | host-owned route registry | `partial` | Route validation and normalization now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. | +| Gateway method registration writes | `src/plugins/registry.ts` | host-owned runtime contribution registry | `partial` | Duplicate detection and normalized method registration now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. | +| Tool registration writes | `src/plugins/registry.ts` | host-owned tool registry | `partial` | Tool-name normalization and tool-factory shaping now delegate to `src/extension-host/runtime-registrations.ts`, but duplicate handling still follows the legacy tool path. | +| CLI registration writes | `src/plugins/registry.ts` | host-owned CLI registry | `partial` | CLI command-name normalization now delegates to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. | +| Service registration writes | `src/plugins/registry.ts` | host-owned service registry | `partial` | Service-id normalization now delegates to `src/extension-host/runtime-registrations.ts`, but lifecycle remains legacy-owned. | +| Command registration writes | `src/plugins/registry.ts` | host-owned command registry | `partial` | Command-name normalization now delegates to `src/extension-host/runtime-registrations.ts`, but duplicate enforcement still depends on the legacy plugin command registry. | +| Context-engine registration writes | `src/plugins/registry.ts` | host-owned context-engine registry | `partial` | Context-engine id normalization now delegates to `src/extension-host/runtime-registrations.ts`, but the actual context-engine registry remains legacy-owned. | +| Legacy hook registration writes | `src/plugins/registry.ts` | host-owned hook registry | `partial` | Hook-entry construction and event normalization now delegate to `src/extension-host/runtime-registrations.ts`, but internal-hook bridging still remains in the legacy plugin registry. | +| Typed-hook registration writes | `src/plugins/registry.ts` | host-owned typed-hook registry | `partial` | Typed-hook record construction and hook-name validation now delegate to `src/extension-host/runtime-registrations.ts`, but prompt-injection policy and execution semantics remain legacy-owned. | +| Hook execution and global runner | `src/plugins/hook-runner-global.ts`, `src/hooks/internal-hooks.ts`, plugin hook registration in `src/plugins/registry.ts` | canonical kernel event stages + host bridges | `not started` | No canonical event-stage migration has landed yet. | +| Service lifecycle | `src/plugins/services.ts` and plugin service registration | extension host lifecycle | `not started` | Service startup and teardown still depend on legacy plugin registry/service ownership. | +| CLI registration | plugin CLI registration in `src/plugins/registry.ts` and CLI loaders | extension host registry + static descriptors where possible | `not started` | No host-owned CLI registry exists yet. | +| Gateway/server methods | `src/plugins/registry.ts` gateway handler registration | host-owned runtime contribution registry | `not started` | Still registered directly into the legacy plugin registry. | +| Slot arbitration | `src/plugins/slots.ts` | host-owned arbitration model | `not started` | Current slot selection remains plugin-era logic. | +| ACP backend registry | `src/acp/runtime/registry.ts` | host-owned runtime-backend registry | `not started` | ACP backends still mutate a global ACP runtime registry directly. | +| Onboarding/install/setup surfaces | `src/plugins/install.ts`, package manifests, channel catalog, onboarding commands | host-owned static descriptors | `partial` | Static metadata normalization has started; full setup/install descriptor migration is not done. | +| Pilot migrations | `extensions/thread-ownership`, `extensions/telegram`, `extensions/acpx` | extension-host path with parity tracking | `not started` | No pilot runs through the host path yet. | ## Completed Pattern So Far diff --git a/src/extension-host/loader-finalization-policy.ts b/src/extension-host/loader-finalization-policy.ts index cff3dfa6037..c16bf153a58 100644 --- a/src/extension-host/loader-finalization-policy.ts +++ b/src/extension-host/loader-finalization-policy.ts @@ -1,49 +1,10 @@ -import fs from "node:fs"; -import path from "node:path"; import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginDiagnostic } from "../plugins/types.js"; -import type { ExtensionHostProvenanceIndex } from "./loader-policy.js"; - -function safeRealpathOrResolve(value: string): string { - try { - return fs.realpathSync(value); - } catch { - return path.resolve(value); - } -} - -function matchesPathMatcher( - matcher: { exact: Set; dirs: string[] }, - sourcePath: string, -): boolean { - if (matcher.exact.has(sourcePath)) { - return true; - } - return matcher.dirs.some( - (dirPath) => sourcePath === dirPath || sourcePath.startsWith(`${dirPath}/`), - ); -} - -function isTrackedByProvenance(params: { - pluginId: string; - source: string; - index: ExtensionHostProvenanceIndex; - env: NodeJS.ProcessEnv; -}): boolean { - const sourcePath = params.source.startsWith("~") - ? `${params.env.HOME ?? ""}${params.source.slice(1)}` - : params.source; - const installRule = params.index.installRules.get(params.pluginId); - if (installRule) { - if (installRule.trackedWithoutPaths) { - return true; - } - if (matchesPathMatcher(installRule.matcher, sourcePath)) { - return true; - } - } - return matchesPathMatcher(params.index.loadPathMatcher, sourcePath); -} +import { + isExtensionHostTrackedByProvenance, + safeRealpathOrResolveExtensionHostPath, + type ExtensionHostProvenanceIndex, +} from "./loader-provenance.js"; export function resolveExtensionHostFinalizationPolicy(params: { registry: PluginRegistry; @@ -70,7 +31,7 @@ export function resolveExtensionHostFinalizationPolicy(params: { continue; } if ( - isTrackedByProvenance({ + isExtensionHostTrackedByProvenance({ pluginId: plugin.id, source: plugin.source, index: params.provenance, @@ -88,7 +49,7 @@ export function resolveExtensionHostFinalizationPolicy(params: { message, }); warningMessages.push( - `[plugins] ${plugin.id}: ${message} (${safeRealpathOrResolve(plugin.source)})`, + `[plugins] ${plugin.id}: ${message} (${safeRealpathOrResolveExtensionHostPath(plugin.source)})`, ); } diff --git a/src/extension-host/loader-orchestrator.ts b/src/extension-host/loader-orchestrator.ts index f66a67de9f6..5dc966f8643 100644 --- a/src/extension-host/loader-orchestrator.ts +++ b/src/extension-host/loader-orchestrator.ts @@ -7,7 +7,6 @@ import { getCachedExtensionHostRegistry, setCachedExtensionHostRegistry, } from "../extension-host/loader-cache.js"; -import { resolveExtensionHostDiscoveryPolicy } from "../extension-host/loader-discovery-policy.js"; import { buildExtensionHostProvenanceIndex, compareExtensionHostDuplicateCandidateOrder, @@ -24,6 +23,7 @@ import { createPluginRuntime, type CreatePluginRuntimeOptions } from "../plugins import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { OpenClawPluginModule, PluginLogger } from "../plugins/types.js"; import { resolvePluginSdkAlias, resolvePluginSdkScopedAliasMap } from "./loader-compat.js"; +import { resolveExtensionHostDiscoveryPolicy } from "./loader-discovery-policy.js"; import { createExtensionHostLoaderSession, finalizeExtensionHostLoaderSession, diff --git a/src/extension-host/loader-policy.ts b/src/extension-host/loader-policy.ts index 1fcd957a77a..d413fc735ad 100644 --- a/src/extension-host/loader-policy.ts +++ b/src/extension-host/loader-policy.ts @@ -1,29 +1,19 @@ import type { OpenClawConfig } from "../config/config.js"; import type { PluginCandidate } from "../plugins/discovery.js"; -import { isPathInside, safeStatSync } from "../plugins/path-safety.js"; import type { PluginRecord, PluginRegistry } from "../plugins/registry.js"; import type { PluginDiagnostic, PluginLogger } from "../plugins/types.js"; -import { resolveUserPath } from "../utils.js"; +import { + addExtensionHostPathToMatcher, + createExtensionHostPathMatcher, + matchesExplicitExtensionHostInstallRule, + type ExtensionHostInstallTrackingRule, + type ExtensionHostProvenanceIndex, +} from "./loader-provenance.js"; import { appendExtensionHostPluginRecord, setExtensionHostPluginRecordLifecycleState, } from "./loader-state.js"; -type PathMatcher = { - exact: Set; - dirs: string[]; -}; - -type InstallTrackingRule = { - trackedWithoutPaths: boolean; - matcher: PathMatcher; -}; - -export type ExtensionHostProvenanceIndex = { - loadPathMatcher: PathMatcher; - installRules: Map; -}; - export function createExtensionHostPluginRecord(params: { id: string; name?: string; @@ -106,57 +96,22 @@ export function pushExtensionHostDiagnostics( diagnostics.push(...append); } -function createPathMatcher(): PathMatcher { - return { exact: new Set(), dirs: [] }; -} - -function addPathToMatcher( - matcher: PathMatcher, - rawPath: string, - env: NodeJS.ProcessEnv = process.env, -): void { - const trimmed = rawPath.trim(); - if (!trimmed) { - return; - } - const resolved = resolveUserPath(trimmed, env); - if (!resolved) { - return; - } - if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) { - return; - } - const stat = safeStatSync(resolved); - if (stat?.isDirectory()) { - matcher.dirs.push(resolved); - return; - } - matcher.exact.add(resolved); -} - -function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean { - if (matcher.exact.has(sourcePath)) { - return true; - } - return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath)); -} - export function buildExtensionHostProvenanceIndex(params: { config: OpenClawConfig; normalizedLoadPaths: string[]; env: NodeJS.ProcessEnv; }): ExtensionHostProvenanceIndex { - const loadPathMatcher = createPathMatcher(); + const loadPathMatcher = createExtensionHostPathMatcher(); for (const loadPath of params.normalizedLoadPaths) { - addPathToMatcher(loadPathMatcher, loadPath, params.env); + addExtensionHostPathToMatcher(loadPathMatcher, loadPath, params.env); } - const installRules = new Map(); + const installRules = new Map(); const installs = params.config.plugins?.installs ?? {}; for (const [pluginId, install] of Object.entries(installs)) { - const rule: InstallTrackingRule = { + const rule: ExtensionHostInstallTrackingRule = { trackedWithoutPaths: false, - matcher: createPathMatcher(), + matcher: createExtensionHostPathMatcher(), }; const trackedPaths = [install.installPath, install.sourcePath] .map((entry) => (typeof entry === "string" ? entry.trim() : "")) @@ -165,7 +120,7 @@ export function buildExtensionHostProvenanceIndex(params: { rule.trackedWithoutPaths = true; } else { for (const trackedPath of trackedPaths) { - addPathToMatcher(rule.matcher, trackedPath, params.env); + addExtensionHostPathToMatcher(rule.matcher, trackedPath, params.env); } } installRules.set(pluginId, rule); @@ -174,20 +129,6 @@ export function buildExtensionHostProvenanceIndex(params: { return { loadPathMatcher, installRules }; } -function matchesExplicitInstallRule(params: { - pluginId: string; - source: string; - index: ExtensionHostProvenanceIndex; - env: NodeJS.ProcessEnv; -}): boolean { - const sourcePath = resolveUserPath(params.source, params.env); - const installRule = params.index.installRules.get(params.pluginId); - if (!installRule || installRule.trackedWithoutPaths) { - return false; - } - return matchesPathMatcher(installRule.matcher, sourcePath); -} - function resolveCandidateDuplicateRank(params: { candidate: PluginCandidate; manifestByRoot: Map; @@ -199,7 +140,7 @@ function resolveCandidateDuplicateRank(params: { const isExplicitInstall = params.candidate.origin === "global" && pluginId !== undefined && - matchesExplicitInstallRule({ + matchesExplicitExtensionHostInstallRule({ pluginId, source: params.candidate.source, index: params.provenance, diff --git a/src/extension-host/loader-provenance.test.ts b/src/extension-host/loader-provenance.test.ts new file mode 100644 index 00000000000..74964ce5288 --- /dev/null +++ b/src/extension-host/loader-provenance.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + addExtensionHostPathToMatcher, + createExtensionHostPathMatcher, + isExtensionHostTrackedByProvenance, + matchesExplicitExtensionHostInstallRule, + safeRealpathOrResolveExtensionHostPath, + type ExtensionHostProvenanceIndex, +} from "./loader-provenance.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-provenance-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("extension host loader provenance", () => { + it("tracks plugins by load path directories", () => { + const trackedDir = makeTempDir(); + const trackedFile = path.join(trackedDir, "tracked.js"); + fs.writeFileSync(trackedFile, "export {};\n", "utf8"); + + const loadPathMatcher = createExtensionHostPathMatcher(); + addExtensionHostPathToMatcher(loadPathMatcher, trackedDir); + + const index: ExtensionHostProvenanceIndex = { + loadPathMatcher, + installRules: new Map(), + }; + + expect( + isExtensionHostTrackedByProvenance({ + pluginId: "tracked", + source: trackedFile, + index, + env: process.env, + }), + ).toBe(true); + }); + + it("matches explicit install rules only when tracked paths are present", () => { + const installDir = makeTempDir(); + const installFile = path.join(installDir, "plugin.js"); + fs.writeFileSync(installFile, "export {};\n", "utf8"); + + const installMatcher = createExtensionHostPathMatcher(); + addExtensionHostPathToMatcher(installMatcher, installDir); + + const index: ExtensionHostProvenanceIndex = { + loadPathMatcher: createExtensionHostPathMatcher(), + installRules: new Map([ + [ + "demo", + { + trackedWithoutPaths: false, + matcher: installMatcher, + }, + ], + ]), + }; + + expect( + matchesExplicitExtensionHostInstallRule({ + pluginId: "demo", + source: installFile, + index, + env: process.env, + }), + ).toBe(true); + }); + + it("falls back to resolved paths when realpath fails", () => { + const missingPath = path.join(makeTempDir(), "missing.js"); + + expect(safeRealpathOrResolveExtensionHostPath(missingPath)).toBe(path.resolve(missingPath)); + }); +}); diff --git a/src/extension-host/loader-provenance.ts b/src/extension-host/loader-provenance.ts new file mode 100644 index 00000000000..ab565303cc3 --- /dev/null +++ b/src/extension-host/loader-provenance.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isPathInside, safeStatSync } from "../plugins/path-safety.js"; +import { resolveUserPath } from "../utils.js"; + +export type ExtensionHostPathMatcher = { + exact: Set; + dirs: string[]; +}; + +export type ExtensionHostInstallTrackingRule = { + trackedWithoutPaths: boolean; + matcher: ExtensionHostPathMatcher; +}; + +export type ExtensionHostProvenanceIndex = { + loadPathMatcher: ExtensionHostPathMatcher; + installRules: Map; +}; + +export function safeRealpathOrResolveExtensionHostPath(value: string): string { + try { + return fs.realpathSync(value); + } catch { + return path.resolve(value); + } +} + +export function createExtensionHostPathMatcher(): ExtensionHostPathMatcher { + return { exact: new Set(), dirs: [] }; +} + +export function addExtensionHostPathToMatcher( + matcher: ExtensionHostPathMatcher, + rawPath: string, + env: NodeJS.ProcessEnv = process.env, +): void { + const trimmed = rawPath.trim(); + if (!trimmed) { + return; + } + const resolved = resolveUserPath(trimmed, env); + if (!resolved) { + return; + } + if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) { + return; + } + const stat = safeStatSync(resolved); + if (stat?.isDirectory()) { + matcher.dirs.push(resolved); + return; + } + matcher.exact.add(resolved); +} + +export function matchesExtensionHostPathMatcher( + matcher: ExtensionHostPathMatcher, + sourcePath: string, +): boolean { + if (matcher.exact.has(sourcePath)) { + return true; + } + return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath)); +} + +export function isExtensionHostTrackedByProvenance(params: { + pluginId: string; + source: string; + index: ExtensionHostProvenanceIndex; + env: NodeJS.ProcessEnv; +}): boolean { + const sourcePath = resolveUserPath(params.source, params.env); + const installRule = params.index.installRules.get(params.pluginId); + if (installRule) { + if (installRule.trackedWithoutPaths) { + return true; + } + if (matchesExtensionHostPathMatcher(installRule.matcher, sourcePath)) { + return true; + } + } + return matchesExtensionHostPathMatcher(params.index.loadPathMatcher, sourcePath); +} + +export function matchesExplicitExtensionHostInstallRule(params: { + pluginId: string; + source: string; + index: ExtensionHostProvenanceIndex; + env: NodeJS.ProcessEnv; +}): boolean { + const sourcePath = resolveUserPath(params.source, params.env); + const installRule = params.index.installRules.get(params.pluginId); + if (!installRule || installRule.trackedWithoutPaths) { + return false; + } + return matchesExtensionHostPathMatcher(installRule.matcher, sourcePath); +}