diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index e637eecaf4f..4d0e64c4ad1 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -43,6 +43,7 @@ This is an implementation checklist, not a future-design spec. | 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. | @@ -89,14 +90,14 @@ That pattern has been used for: - active registry ownership - normalized extension schema and resolved-extension records - static consumers such as skills, validation, auto-enable, and config baseline generation -- loader compatibility, cache control, initial candidate planning, entry-path import, explicit activation-policy outcomes, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, and final cache plus activation finalization +- loader compatibility, cache control, initial candidate planning, entry-path import, explicit activation-policy outcomes, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, explicit finalization-policy results, and final cache plus activation finalization ## Immediate Next Targets These are the next lowest-risk cutover steps: 1. Replace remaining static-only manifest-registry injections with resolved-extension registry inputs where practical. -2. Extend the new loader lifecycle state machine, session-owned activation state, and activation-policy outcomes into broader activation-state and policy ownership in `src/extension-host/*`. +2. Extend the new loader lifecycle state machine, session-owned activation state, activation-policy outcomes, and finalization-policy results into broader activation-state and policy ownership in `src/extension-host/*`. 3. Introduce explicit host-owned registration surfaces for runtime writes, starting with the least-coupled registries. 4. Move minimal SDK compatibility and loader normalization into `src/extension-host/*` without breaking current `openclaw/plugin-sdk/*` loading. 5. Start the first pilot on `extensions/thread-ownership` only after the host-side registry and lifecycle seams are explicit. diff --git a/src/extension-host/loader-finalization-policy.test.ts b/src/extension-host/loader-finalization-policy.test.ts new file mode 100644 index 00000000000..68ef023acad --- /dev/null +++ b/src/extension-host/loader-finalization-policy.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { PluginRegistry } from "../plugins/registry.js"; +import { resolveExtensionHostFinalizationPolicy } from "./loader-finalization-policy.js"; + +function createRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader finalization policy", () => { + it("emits memory-slot diagnostics when no selected memory plugin matched", () => { + const result = resolveExtensionHostFinalizationPolicy({ + registry: createRegistry(), + memorySlot: "memory-a", + memorySlotMatched: false, + provenance: { + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }, + env: process.env, + }); + + expect(result.diagnostics).toContainEqual({ + level: "warn", + message: "memory slot plugin not found or not marked as memory: memory-a", + }); + }); + + it("emits provenance warnings for untracked non-bundled plugins", () => { + const registry = createRegistry(); + registry.plugins.push({ + id: "demo", + name: "demo", + source: "/tmp/demo/index.js", + origin: "workspace", + enabled: true, + status: "loaded", + lifecycleState: "ready", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }); + + const result = resolveExtensionHostFinalizationPolicy({ + registry, + memorySlotMatched: true, + provenance: { + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }, + env: process.env, + }); + + expect(result.diagnostics).toContainEqual({ + level: "warn", + pluginId: "demo", + source: "/tmp/demo/index.js", + message: + "loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records", + }); + expect(result.warningMessages[0]).toContain("[plugins] demo:"); + }); +}); diff --git a/src/extension-host/loader-finalization-policy.ts b/src/extension-host/loader-finalization-policy.ts new file mode 100644 index 00000000000..cff3dfa6037 --- /dev/null +++ b/src/extension-host/loader-finalization-policy.ts @@ -0,0 +1,96 @@ +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); +} + +export function resolveExtensionHostFinalizationPolicy(params: { + registry: PluginRegistry; + memorySlot?: string | null; + memorySlotMatched: boolean; + provenance: ExtensionHostProvenanceIndex; + env: NodeJS.ProcessEnv; +}): { + diagnostics: PluginDiagnostic[]; + warningMessages: string[]; +} { + const diagnostics: PluginDiagnostic[] = []; + const warningMessages: string[] = []; + + if (typeof params.memorySlot === "string" && !params.memorySlotMatched) { + diagnostics.push({ + level: "warn", + message: `memory slot plugin not found or not marked as memory: ${params.memorySlot}`, + }); + } + + for (const plugin of params.registry.plugins) { + if (plugin.status !== "loaded" || plugin.origin === "bundled") { + continue; + } + if ( + isTrackedByProvenance({ + pluginId: plugin.id, + source: plugin.source, + index: params.provenance, + env: params.env, + }) + ) { + continue; + } + const message = + "loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records"; + diagnostics.push({ + level: "warn", + pluginId: plugin.id, + source: plugin.source, + message, + }); + warningMessages.push( + `[plugins] ${plugin.id}: ${message} (${safeRealpathOrResolve(plugin.source)})`, + ); + } + + return { diagnostics, warningMessages }; +} diff --git a/src/extension-host/loader-finalize.ts b/src/extension-host/loader-finalize.ts index f6b472a304e..a5c2c2ca1ce 100644 --- a/src/extension-host/loader-finalize.ts +++ b/src/extension-host/loader-finalize.ts @@ -1,7 +1,7 @@ import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginLogger } from "../plugins/types.js"; +import { resolveExtensionHostFinalizationPolicy } from "./loader-finalization-policy.js"; import type { ExtensionHostProvenanceIndex } from "./loader-policy.js"; -import { warnAboutUntrackedLoadedExtensions } from "./loader-policy.js"; import { markExtensionHostRegistryPluginsReady } from "./loader-state.js"; export function finalizeExtensionHostRegistryLoad(params: { @@ -16,19 +16,17 @@ export function finalizeExtensionHostRegistryLoad(params: { setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void; activateRegistry: (registry: PluginRegistry, cacheKey: string) => void; }): PluginRegistry { - if (typeof params.memorySlot === "string" && !params.memorySlotMatched) { - params.registry.diagnostics.push({ - level: "warn", - message: `memory slot plugin not found or not marked as memory: ${params.memorySlot}`, - }); - } - - warnAboutUntrackedLoadedExtensions({ + const finalizationPolicy = resolveExtensionHostFinalizationPolicy({ registry: params.registry, + memorySlot: params.memorySlot, + memorySlotMatched: params.memorySlotMatched, provenance: params.provenance, - logger: params.logger, env: params.env, }); + params.registry.diagnostics.push(...finalizationPolicy.diagnostics); + for (const warning of finalizationPolicy.warningMessages) { + params.logger.warn(warning); + } if (params.cacheEnabled) { params.setCachedRegistry(params.cacheKey, params.registry);