From b195ce5275d64d1e36d8d0049ced95fd993ad05e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 16:58:05 +0000 Subject: [PATCH] Plugins: extract hook compatibility --- src/extension-host/cutover-inventory.md | 1 + src/extension-host/hook-compat.test.ts | 66 ++++++++++++++++++ src/extension-host/hook-compat.ts | 91 +++++++++++++++++++++++++ src/plugins/registry.ts | 76 +++++++++------------ 4 files changed, 189 insertions(+), 45 deletions(-) create mode 100644 src/extension-host/hook-compat.test.ts create mode 100644 src/extension-host/hook-compat.ts diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index fe13f47002a..b60392bae0a 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -75,6 +75,7 @@ This is an implementation checklist, not a future-design spec. | 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`, and the compatibility write now routes through `src/extension-host/registry-writes.ts`; 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`, and the compatibility write now routes through `src/extension-host/registry-writes.ts`; 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`, and the compatibility write now routes through `src/extension-host/registry-writes.ts`; prompt-injection policy and execution semantics remain legacy-owned. | +| Hook compatibility policy and bridging | `src/plugins/registry.ts` | `src/extension-host/hook-compat.ts` | `partial` | Legacy internal-hook bridging and typed prompt-injection compatibility policy now delegate through a host-owned helper; actual hook execution ownership remains 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. | diff --git a/src/extension-host/hook-compat.test.ts b/src/extension-host/hook-compat.test.ts new file mode 100644 index 00000000000..f98e108875a --- /dev/null +++ b/src/extension-host/hook-compat.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyExtensionHostTypedHookPolicy, + bridgeExtensionHostLegacyHooks, + constrainExtensionHostPromptInjectionHook, +} from "./hook-compat.js"; + +describe("extension host hook compatibility", () => { + it("bridges legacy hooks only when internal hook registration is enabled", () => { + const registerHook = vi.fn(); + + bridgeExtensionHostLegacyHooks({ + events: ["before_send", "after_send"], + handler: (() => {}) as never, + hookSystemEnabled: true, + registerHook: registerHook as never, + }); + + expect(registerHook).toHaveBeenCalledTimes(2); + expect(registerHook).toHaveBeenNthCalledWith(1, "before_send", expect.any(Function)); + expect(registerHook).toHaveBeenNthCalledWith(2, "after_send", expect.any(Function)); + }); + + it("constrains prompt-mutation fields for before_agent_start hooks", async () => { + const handler = vi.fn(async () => ({ + messages: [{ role: "system", content: "keep" }], + systemPrompt: "drop", + prependContext: "drop", + appendSystemContext: "drop", + })); + + const constrained = constrainExtensionHostPromptInjectionHook(handler as never); + const result = await constrained({} as never, {} as never); + + expect(result).toEqual({ + messages: [{ role: "system", content: "keep" }], + }); + }); + + it("blocks before_prompt_build and constrains before_agent_start when prompt injection is disabled", () => { + const blocked = applyExtensionHostTypedHookPolicy({ + hookName: "before_prompt_build", + handler: (() => ({})) as never, + policy: { allowPromptInjection: false }, + blockedMessage: "blocked", + constrainedMessage: "constrained", + }); + const constrained = applyExtensionHostTypedHookPolicy({ + hookName: "before_agent_start", + handler: (() => ({})) as never, + policy: { allowPromptInjection: false }, + blockedMessage: "blocked", + constrainedMessage: "constrained", + }); + + expect(blocked).toEqual({ + ok: false, + message: "blocked", + }); + expect(constrained.ok).toBe(true); + if (constrained.ok) { + expect(constrained.warningMessage).toBe("constrained"); + expect(constrained.entryHandler).toBeTypeOf("function"); + } + }); +}); diff --git a/src/extension-host/hook-compat.ts b/src/extension-host/hook-compat.ts new file mode 100644 index 00000000000..348e16aeaea --- /dev/null +++ b/src/extension-host/hook-compat.ts @@ -0,0 +1,91 @@ +import { registerInternalHook, type InternalHookHandler } from "../hooks/internal-hooks.js"; +import type { + PluginHookHandlerMap, + PluginHookName, + PluginHookRegistration as TypedPluginHookRegistration, +} from "../plugins/types.js"; +import { + isPromptInjectionHookName, + stripPromptMutationFieldsFromLegacyHookResult, +} from "../plugins/types.js"; + +export function constrainExtensionHostPromptInjectionHook( + handler: PluginHookHandlerMap["before_agent_start"], +): PluginHookHandlerMap["before_agent_start"] { + return (event, ctx) => { + const result = handler(event, ctx); + if (result && typeof result === "object" && "then" in result) { + return Promise.resolve(result).then((resolved) => + stripPromptMutationFieldsFromLegacyHookResult(resolved), + ); + } + return stripPromptMutationFieldsFromLegacyHookResult(result); + }; +} + +export function bridgeExtensionHostLegacyHooks(params: { + events: string[]; + handler: InternalHookHandler; + hookSystemEnabled: boolean; + register?: boolean; + registerHook?: typeof registerInternalHook; +}): void { + if (!params.hookSystemEnabled || params.register === false) { + return; + } + + const registerHook = params.registerHook ?? registerInternalHook; + for (const event of params.events) { + registerHook(event, params.handler); + } +} + +export function applyExtensionHostTypedHookPolicy(params: { + hookName: K; + handler: PluginHookHandlerMap[K]; + policy?: { + allowPromptInjection?: boolean; + }; + blockedMessage: string; + constrainedMessage: string; +}): + | { + ok: false; + message: string; + } + | { + ok: true; + entryHandler: TypedPluginHookRegistration["handler"]; + warningMessage?: string; + } { + if ( + !(params.policy?.allowPromptInjection === false && isPromptInjectionHookName(params.hookName)) + ) { + return { + ok: true, + entryHandler: params.handler, + }; + } + + if (params.hookName === "before_prompt_build") { + return { + ok: false, + message: params.blockedMessage, + }; + } + + if (params.hookName === "before_agent_start") { + return { + ok: true, + entryHandler: constrainExtensionHostPromptInjectionHook( + params.handler as PluginHookHandlerMap["before_agent_start"], + ), + warningMessage: params.constrainedMessage, + }; + } + + return { + ok: true, + entryHandler: params.handler, + }; +} diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 6bc44b82ca5..4c11e99ccd2 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -179,20 +179,6 @@ type PluginTypedHookPolicy = { allowPromptInjection?: boolean; }; -const constrainLegacyPromptInjectionHook = ( - handler: PluginHookHandlerMap["before_agent_start"], -): PluginHookHandlerMap["before_agent_start"] => { - return (event, ctx) => { - const result = handler(event, ctx); - if (result && typeof result === "object" && "then" in result) { - return Promise.resolve(result).then((resolved) => - stripPromptMutationFieldsFromLegacyHookResult(resolved), - ); - } - return stripPromptMutationFieldsFromLegacyHookResult(result); - }; -}; - export function createEmptyPluginRegistry(): PluginRegistry { return { plugins: [], @@ -320,14 +306,13 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { events: normalized.events, }); - const hookSystemEnabled = config?.hooks?.internal?.enabled === true; - if (!hookSystemEnabled || opts?.register === false) { - return; - } - - for (const event of normalized.events) { - registerInternalHook(event, handler); - } + bridgeExtensionHostLegacyHooks({ + events: normalized.events, + handler, + hookSystemEnabled: config?.hooks?.internal?.enabled === true, + register: opts?.register, + registerHook: registerInternalHook, + }); }; const registerGatewayMethod = ( @@ -620,28 +605,29 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - let effectiveHandler = normalized.entry.handler; - if (policy?.allowPromptInjection === false && isPromptInjectionHookName(normalized.hookName)) { - if (normalized.hookName === "before_prompt_build") { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `typed hook "${normalized.hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, - }); - return; - } - if (normalized.hookName === "before_agent_start") { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: `typed hook "${normalized.hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, - }); - effectiveHandler = constrainLegacyPromptInjectionHook( - handler as PluginHookHandlerMap["before_agent_start"], - ) as PluginHookHandlerMap[K]; - } + const policyResult = applyExtensionHostTypedHookPolicy({ + hookName: normalized.hookName, + handler, + policy, + blockedMessage: `typed hook "${normalized.hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + constrainedMessage: `typed hook "${normalized.hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + }); + if (!policyResult.ok) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: policyResult.message, + }); + return; + } + if (policyResult.warningMessage) { + pushDiagnostic({ + level: "warn", + pluginId: record.id, + source: record.source, + message: policyResult.warningMessage, + }); } addExtensionTypedHookRegistration({ registry, @@ -650,7 +636,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { ...normalized.entry, pluginId: record.id, hookName: normalized.hookName, - handler: effectiveHandler, + handler: policyResult.entryHandler, } as TypedPluginHookRegistration, }); };