Plugins: extract hook compatibility

This commit is contained in:
Gustavo Madeira Santana
2026-03-15 16:58:05 +00:00
parent 8bfe6bb03f
commit b195ce5275
4 changed files with 189 additions and 45 deletions

View File

@@ -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. |

View File

@@ -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");
}
});
});

View File

@@ -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<K extends PluginHookName>(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,
};
}

View File

@@ -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,
});
};