diff --git a/docs/plans/hooks-and-feature-packs.md b/docs/plans/hooks-and-feature-packs.md index cbb74826ee..f041225c38 100644 --- a/docs/plans/hooks-and-feature-packs.md +++ b/docs/plans/hooks-and-feature-packs.md @@ -201,6 +201,19 @@ revisiting this document, not silently diverging in an implementation PR. tabs and durable session remain. Direct network, arbitrary IPC/Electron access, generic host calls, and unused renderer contribution placeholders remain unavailable. + **External function-hook registration (SDK stage 1):** a selected user plugin may + declare `runtime.hooks: [{ id, event }]`, including a hook-only runtime. Events + use the canonical catalogue; IDs are unique per plugin. API v1 adds + `registerHook(definition, handler)` during activation and an `invoke-hook` + request carrying the registration ID, event, and opaque JSON input. Both the + host and worker check the registration/event pair; startup rejects missing, + extra, duplicate, or event-mismatched registrations. Handlers receive only + `{ event, signal }`, never first-party context, feature-chunk emission, browser + or session authority. Existing workers may omit the hooks list. This stage + establishes registration and explicit host invocation only: canonical fire + sites do not dispatch these hooks yet, and results are not interpreted as + decisions or transformations. Automatic dispatch, event-specific payload/result + validation, and `next()` composition require their own implementation stages. 16. **Async hook outputs are epoch-scoped to their emitting turn tree.** Send-now currently aborts the active local run (`sendQueuedMessageNow` in `src/renderer/controller/message-queue.ts`), so a late async hook from a completed diff --git a/docs/plugins.md b/docs/plugins.md index 16a1f72d51..fde7bc3694 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -46,6 +46,13 @@ the runtime `RegisteredPlugin.contributions`, not in the serializable manifest. The one user-code exception is an explicitly selected plugin's isolated shared `runtime`; it never imports code into Electron main. +The isolated runtime also supports declared `runtime.hooks` registrations through +`registerHook` and explicit host invocation. Hook-only runtimes are accepted. This +is the SDK/protocol stage: the agent's canonical event fire sites do not invoke +these external functions yet, and their results cannot alter agent behavior. +See the [SDK contract](../packages/plugin-sdk/README.md#external-function-hook-registration) +for declaration matching, cancellation, and the deliberately narrow handler context. + `pluginManifestFromCursorJson()` maps a Cursor-shaped `plugin.json` into a `PluginManifest` (a user plugin): the existing top-level `skills` / `mcpServers` fields fold into the plugin slots (`mcpServers` → `tools.mcpServers`). The diff --git a/packages/agent/src/plugins/agent-plugin-manifest.test.ts b/packages/agent/src/plugins/agent-plugin-manifest.test.ts index ce98aad0a1..a21472c69e 100644 --- a/packages/agent/src/plugins/agent-plugin-manifest.test.ts +++ b/packages/agent/src/plugins/agent-plugin-manifest.test.ts @@ -35,6 +35,15 @@ function copse(block: Record): Record { } describe('isValidAgentPluginName', () => { + it('preserves validated runtime hook declarations in the Copse extension', () => { + const runtime = { + entrypoint: 'dist/index.mjs', + apiVersion: 1, + hooks: [{ id: 'inspect', event: 'turnStart' }], + } + const parsed = parseAgentPluginManifest(manifest(copse({ runtime }))) + assert.deepEqual(parsed.manifest.runtime, runtime) + }) it('accepts the spec §5.5 examples', () => { for (const name of ['my-plugin', 'acme.tools', 'lint3r', 'a']) { assert.equal(isValidAgentPluginName(name), true, name) diff --git a/packages/agent/src/plugins/agent-plugin-manifest.ts b/packages/agent/src/plugins/agent-plugin-manifest.ts index efc423a4cd..341e619a6e 100644 --- a/packages/agent/src/plugins/agent-plugin-manifest.ts +++ b/packages/agent/src/plugins/agent-plugin-manifest.ts @@ -27,6 +27,7 @@ // Electron-free (execution-guidance rule 4): the host disk walk that feeds this // a parsed object lives in `src/main/services/plugins/discover-user-plugins.ts`. import { z } from 'zod' +import { zPluginHookRegistrations } from './plugin-hook.ts' import type { PluginBrowserDecl, PluginCapabilityDecl, @@ -173,6 +174,7 @@ const zBrowserDecl = z.strictObject({ const zRuntimeDecl = z.strictObject({ entrypoint: z.string().min(1).max(1_000), apiVersion: z.literal(1), + hooks: zPluginHookRegistrations.min(1).optional(), }) const zCommandHook = z.strictObject({ @@ -429,6 +431,7 @@ function applyCopseExtension( const runtime: PluginToolRuntimeDecl = { entrypoint: extension.runtime.entrypoint, apiVersion: extension.runtime.apiVersion, + ...(extension.runtime.hooks ? { hooks: extension.runtime.hooks } : {}), } manifest.runtime = runtime } diff --git a/packages/agent/src/plugins/plugin-hook.test.ts b/packages/agent/src/plugins/plugin-hook.test.ts new file mode 100644 index 0000000000..f1d06c0567 --- /dev/null +++ b/packages/agent/src/plugins/plugin-hook.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { HOOK_EVENT_NAMES } from '../hooks/canonical-events.ts' +import { + validatePluginHookRegistrations, + zPluginHookRegistration, + zPluginHookRegistrations, +} from './plugin-hook.ts' + +describe('external hook declarations', () => { + it('accepts every canonical event and rejects unknown events and malformed declarations', () => { + for (const event of HOOK_EVENT_NAMES) { + assert.deepEqual(zPluginHookRegistration.parse({ id: 'hook', event }), { id: 'hook', event }) + } + for (const event of [ + '*', + 'tool.call', + 'constructor', + 'toString', + ...HOOK_EVENT_NAMES.map((e) => `${e} `), + ]) { + assert.equal(zPluginHookRegistration.safeParse({ id: 'hook', event }).success, false) + } + for (const value of [ + null, + [], + {}, + { id: '', event: 'turnStart' }, + { id: 'x', event: 'turnStart', command: 'echo x' }, + ]) { + assert.equal(zPluginHookRegistration.safeParse(value).success, false) + } + }) + + it('requires exact ids and events, allowing registration order to differ from declaration order', () => { + const a = zPluginHookRegistration.parse({ id: 'a', event: 'turnStart' }) + const b = zPluginHookRegistration.parse({ id: 'b', event: 'stop' }) + validatePluginHookRegistrations([a, b], [b, a]) + for (const actual of [[], [a], [a, a], [a, { ...b, event: a.event }]]) { + assert.throws(() => { + validatePluginHookRegistrations([a, b], actual) + }) + } + assert.throws(() => { + validatePluginHookRegistrations([], [a]) + }) + assert.equal( + zPluginHookRegistrations.safeParse( + Array.from({ length: 1_001 }, (_, i) => ({ ...a, id: String(i) })), + ).success, + false, + ) + }) +}) diff --git a/packages/agent/src/plugins/plugin-hook.ts b/packages/agent/src/plugins/plugin-hook.ts new file mode 100644 index 0000000000..94021dd5c3 --- /dev/null +++ b/packages/agent/src/plugins/plugin-hook.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' +import { HOOK_EVENT_NAMES } from '../hooks/canonical-events.ts' + +/** A worker hook is declared independently of the existing command-hook dialects. */ +export const zPluginHookRegistration = z.strictObject({ + id: z.string().min(1).max(128), + event: z.enum(HOOK_EVENT_NAMES), +}) + +export const zPluginHookRegistrations = z + .array(zPluginHookRegistration) + .max(1_000) + .refine((hooks) => new Set(hooks.map((hook) => hook.id)).size === hooks.length, { + message: 'Plugin hook ids must be unique.', + }) + +export type PluginHookRegistration = z.infer + +/** A worker may register exactly the hook ids/events selected in its manifest. */ +export function validatePluginHookRegistrations( + declared: readonly PluginHookRegistration[], + registered: readonly PluginHookRegistration[], +): void { + const expected = new Map(zPluginHookRegistrations.parse(declared).map((h) => [h.id, h.event])) + const actual = zPluginHookRegistrations.parse(registered) + if (expected.size !== actual.length || actual.some((h) => expected.get(h.id) !== h.event)) { + throw new Error('Plugin registered hooks not declared by its runtime behavior.') + } +} diff --git a/packages/agent/src/plugins/plugin-manifest.ts b/packages/agent/src/plugins/plugin-manifest.ts index a675f8879f..655e3be343 100644 --- a/packages/agent/src/plugins/plugin-manifest.ts +++ b/packages/agent/src/plugins/plugin-manifest.ts @@ -15,6 +15,7 @@ // (schemas/copse-plugin.schema.json) validates the declarative manifest. import type { AsyncHook, BlockingHook } from '../hooks/canonical-events.ts' import type { PanelContributionDecl } from './plugin-panel.ts' +import type { PluginHookRegistration } from './plugin-hook.ts' /** Host-assigned plugin trust class; disk manifests cannot self-promote. */ export type PluginTrust = 'first-party' | 'user' @@ -215,6 +216,8 @@ export interface PluginStorageDecl { export interface PluginToolRuntimeDecl { entrypoint: string apiVersion: 1 + /** Isolated worker registrations; automatic canonical-event dispatch is a later stage. */ + hooks?: readonly PluginHookRegistration[] } export interface PluginToolsDecl { diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 8ebc46a802..210bb6ed75 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -11,6 +11,55 @@ The plugin layer's design source of truth is `docs/plans/hooks-and-feature-packs the landed architecture is `docs/plugins.md`, and authoring is `docs/adding-a-plugin.md`. This package is the part a plugin author, or a second host, would install. +## External function-hook registration + +API v1 supports isolated hook registration and explicit host invocation. This is +the first stage of function-hook support: Copse's canonical event fire sites do +not invoke these registrations automatically yet. Results are opaque JSON, not +permission decisions or tool-result replacements. The existing command-hook path +is separate. + +An explicitly selected `copse-plugin.json` may contain only a hook runtime: + +```json +{ + "name": "personal.inspect", + "runtime": { + "entrypoint": "index.mjs", + "apiVersion": 1, + "hooks": [{ "id": "inspect-start", "event": "turnStart" }] + } +} +``` + +Its module exports the existing `activate(api)` entry point: + +```js +export function activate(api) { + api.registerHook({ id: 'inspect-start', event: 'turnStart' }, (input, { event, signal }) => { + signal.throwIfAborted() + return { event, received: input } + }) +} +``` + +Hook IDs are unique within the plugin and registrations must exactly match the +declared IDs and events. The manifest order does not constrain activation order; +the handshake preserves registration order. All registrations finish before +`activate` settles; delayed registration is rejected. Events come from Copse's +canonical catalogue (`HookEventName`), with no wildcard or Claude event aliases. +The maximum is 1,000 hooks per plugin. Unknown fields and duplicate IDs fail +validation. Existing API-v1 workers can omit `hooks` in their registration reply. + +The host/controller calls `invokeHook(pluginId, registrationId, event, input, signal)` +(the per-plugin `PluginToolHost` omits `pluginId`). The worker verifies the ID/event +pair and invokes that handler with only `{ event, signal }`. Input and output are +typed `unknown`; callers and handlers must validate the JSON shapes they use. +This transport grants no browser, session, Electron, feature-chunk or `next` +capability. Throws and non-serializable results reject the request; cancellation +uses the existing invocation-scoped abort channel. The runtime's existing OS +sandbox requirement and enable/disable lifecycle still apply. + ## What's in it - **`plugin-tool-sdk.ts`** — what a plugin's `runtime.entrypoint` imports: diff --git a/packages/plugin-sdk/src/plugin-hook.test.ts b/packages/plugin-sdk/src/plugin-hook.test.ts new file mode 100644 index 0000000000..e10f0bbeca --- /dev/null +++ b/packages/plugin-sdk/src/plugin-hook.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { activatePluginTools, type PluginToolActivationApi } from './plugin-tool-sdk.ts' +import { zPluginToolHostRequest, zPluginToolRegistrations } from './plugin-tool-protocol.ts' + +describe('external hook SDK and protocol', () => { + it('registers hooks in order and invokes only the requested handler with narrow context', async () => { + let later: (() => void) | undefined + const seen: string[] = [] + const activated = await activatePluginTools( + { + activate(api: PluginToolActivationApi) { + api.registerHook({ id: 'second', event: 'stop' }, () => { + seen.push('second') + return null + }) + api.registerHook({ id: 'first', event: 'turnStart' }, (input, context) => { + seen.push('first') + assert.deepEqual(Object.keys(context).sort(), ['event', 'signal']) + assert.equal(context.event, 'turnStart') + assert.equal(context.signal, signal) + return { received: input } + }) + later = (): void => { + api.registerHook({ id: 'late', event: 'stop' }, () => null) + } + }, + }, + 'personal.hooks', + 1, + ) + const signal = new AbortController().signal + assert.deepEqual( + activated.registrations.hooks?.map((h) => h.id), + ['second', 'first'], + ) + assert.deepEqual( + await activated.invokeHook('first', 'turnStart', { userText: 'hello' }, signal), + { received: { userText: 'hello' } }, + ) + assert.deepEqual(seen, ['first']) + assert.throws(() => later?.(), /during activate/) + await assert.rejects(activated.invokeHook('first', 'stop', {}, signal), /event mismatch/) + await assert.rejects(activated.invokeHook('missing', 'stop', {}, signal), /Unknown plugin hook/) + const aborted = AbortSignal.abort(new Error('cancelled')) + await assert.rejects(activated.invokeHook('first', 'turnStart', {}, aborted), /cancelled/) + assert.deepEqual(seen, ['first']) + }) + + it('rejects duplicate hook ids even when their events differ', async () => { + await assert.rejects( + activatePluginTools( + { + activate(api: PluginToolActivationApi) { + api.registerHook({ id: 'same', event: 'turnStart' }, () => null) + api.registerHook({ id: 'same', event: 'stop' }, () => null) + }, + }, + 'personal.hooks', + 1, + ), + /Duplicate plugin hook/, + ) + }) + + it('preserves API-v1 registrations and rejects invalid hook protocol fields', () => { + assert.deepEqual(zPluginToolRegistrations.parse({ tools: [], models: [] }), { + tools: [], + models: [], + }) + const request = { id: 1, op: 'invoke-hook', registrationId: 'h', event: 'turnStart', input: {} } + assert.deepEqual(zPluginToolHostRequest.parse(request), request) + for (const invalid of [ + { ...request, event: '*' }, + { ...request, next: 5 }, + { ...request, id: 0 }, + ]) { + assert.equal(zPluginToolHostRequest.safeParse(invalid).success, false) + } + assert.equal( + zPluginToolRegistrations.safeParse({ + tools: [], + models: [], + hooks: [{ id: 'a', event: 'bogus' }], + }).success, + false, + ) + }) +}) diff --git a/packages/plugin-sdk/src/plugin-tool-protocol.ts b/packages/plugin-sdk/src/plugin-tool-protocol.ts index 50737b093a..6c1c55710f 100644 --- a/packages/plugin-sdk/src/plugin-tool-protocol.ts +++ b/packages/plugin-sdk/src/plugin-tool-protocol.ts @@ -1,4 +1,15 @@ import { z } from 'zod' +import { + zPluginHookRegistration, + zPluginHookRegistrations, +} from '@copse/agent/plugins/plugin-hook.ts' + +export { + zPluginHookRegistration, + zPluginHookRegistrations, + validatePluginHookRegistrations, + type PluginHookRegistration, +} from '@copse/agent/plugins/plugin-hook.ts' const zRequestId = z.number().int().positive() const zRegistrationId = z.string().min(1).max(128) @@ -97,6 +108,8 @@ export const zPluginBrowserCall = z.discriminatedUnion('op', [ export const zPluginToolRegistrations = z.strictObject({ tools: z.array(zPluginToolRegistration).max(1_000), models: z.array(zPluginModelRegistration).max(1_000), + // Omitted by existing API-v1 workers; omission means no function hooks. + hooks: zPluginHookRegistrations.optional(), }) export type PluginToolRegistration = z.infer @@ -110,6 +123,13 @@ export type PluginBrowserCall = z.infer export type PluginToolRegistrations = z.infer export const zPluginToolHostRequest = z.discriminatedUnion('op', [ + z.strictObject({ + id: zRequestId, + op: z.literal('invoke-hook'), + registrationId: zRegistrationId, + event: zPluginHookRegistration.shape.event, + input: z.unknown(), + }), z.strictObject({ id: zRequestId, op: z.literal('initialize'), diff --git a/packages/plugin-sdk/src/plugin-tool-sdk.ts b/packages/plugin-sdk/src/plugin-tool-sdk.ts index 1da5a47cc8..2ef3646369 100644 --- a/packages/plugin-sdk/src/plugin-tool-sdk.ts +++ b/packages/plugin-sdk/src/plugin-tool-sdk.ts @@ -2,6 +2,8 @@ import { zPluginModelTurn, zPluginBrowserTab, zPluginToolRegistration, + zPluginHookRegistration, + type PluginHookRegistration, type PluginModelTurn, type PluginBrowserTab, type PluginBrowserUploadFile, @@ -13,6 +15,16 @@ export interface PluginToolInvocationContext { readonly signal: AbortSignal } +export interface PluginHookInvocationContext extends PluginToolInvocationContext { + readonly event: PluginHookRegistration['event'] +} + +/** Payload and return values cross a JSON boundary; handlers must validate their input. */ +export type PluginHookInvocationHandler = ( + input: unknown, + context: PluginHookInvocationContext, +) => unknown + export interface PluginModelSessionApi { get(): Promise set(state: unknown): Promise @@ -51,6 +63,7 @@ export interface PluginToolActivationApi { readonly pluginId: string registerTool(definition: PluginToolRegistration, handler: PluginToolInvocationHandler): void registerModelRoute(id: string, handler: PluginModelInvocationHandler): void + registerHook(definition: PluginHookRegistration, handler: PluginHookInvocationHandler): void } export interface PluginToolModule { @@ -60,6 +73,12 @@ export interface PluginToolModule { export interface ActivatedPluginTools { readonly registrations: PluginToolRegistrations invokeTool(registrationId: string, input: unknown, signal: AbortSignal): Promise + invokeHook( + registrationId: string, + event: PluginHookRegistration['event'], + input: unknown, + signal: AbortSignal, + ): Promise invokeModel( registrationId: string, input: unknown, @@ -101,9 +120,22 @@ export async function activatePluginTools( { definition: PluginToolRegistration; handler: PluginToolInvocationHandler } >() const models = new Map() + const hooks = new Map< + string, + { definition: PluginHookRegistration; handler: PluginHookInvocationHandler } + >() + let registeringHooks = true const api: PluginToolActivationApi = Object.freeze({ apiVersion, pluginId, + registerHook(definition: PluginHookRegistration, handler: PluginHookInvocationHandler): void { + if (!registeringHooks) throw new Error('Plugin hooks must register during activate(api).') + const parsed = zPluginHookRegistration.parse(definition) + if (hooks.has(parsed.id)) throw new Error(`Duplicate plugin hook: ${parsed.id}`) + if (hooks.size >= 1_000) throw new Error('Plugin hook registration limit exceeded.') + if (typeof handler !== 'function') throw new Error(`Hook ${parsed.id} has no handler.`) + hooks.set(parsed.id, { definition: parsed, handler }) + }, registerTool(definition: PluginToolRegistration, handler: PluginToolInvocationHandler): void { const parsed = zPluginToolRegistration.parse(definition) if (tools.has(parsed.name)) throw new Error(`Duplicate plugin tool: ${parsed.name}`) @@ -118,12 +150,25 @@ export async function activatePluginTools( }, }) - await moduleActivate(moduleValue)(api) + try { + await moduleActivate(moduleValue)(api) + } finally { + registeringHooks = false + } return { registrations: { tools: [...tools.values()].map((entry) => entry.definition), models: [...models.keys()].map((id) => ({ id })), + hooks: [...hooks.values()].map((entry) => entry.definition), + }, + async invokeHook(registrationId, event, input, signal): Promise { + signal.throwIfAborted() + const entry = hooks.get(registrationId) + if (!entry) throw new Error(`Unknown plugin hook: ${registrationId}`) + if (entry.definition.event !== event) + throw new Error(`Plugin hook event mismatch: ${registrationId}`) + return await entry.handler(input, Object.freeze({ signal, event })) }, async invokeTool(registrationId, input, signal): Promise { const entry = tools.get(registrationId) diff --git a/packages/plugin-sdk/src/plugin-tool-source.test.ts b/packages/plugin-sdk/src/plugin-tool-source.test.ts index 662f1b9e87..aed0809488 100644 --- a/packages/plugin-sdk/src/plugin-tool-source.test.ts +++ b/packages/plugin-sdk/src/plugin-tool-source.test.ts @@ -38,6 +38,33 @@ function validManifest(): Record { } describe('selected plugin tool discovery', () => { + it('accepts hook-only runtimes and preserves their declarations without installing in-process hooks', async () => { + const runtime = { + entrypoint: 'dist/index.mjs', + apiVersion: 1, + hooks: [{ id: 'inspect', event: 'turnStart' }], + } + const source = await discoverPluginToolSource( + await pluginRoot({ name: 'personal.hooks', runtime }), + ) + assert.deepEqual(source.manifest.runtime, runtime) + const registered = registeredPluginToolSource(source) + assert.equal(registered.trust, 'user') + assert.deepEqual(registered.contributions.toolNames, []) + assert.deepEqual(registered.contributions.blockingHooks, []) + assert.deepEqual(registered.contributions.asyncHooks, []) + for (const hooks of [ + [], + [{ id: 'x', event: '*' }], + [ + { id: 'x', event: 'stop' }, + { id: 'x', event: 'turnStart' }, + ], + ]) { + const root = await pluginRoot({ name: 'personal.hooks', runtime: { ...runtime, hooks } }) + await assert.rejects(discoverPluginToolSource(root), PluginToolSourceError) + } + }) it('validates, canonicalizes, hashes, and registers the declared tool behavior', async () => { const candidate = await discoverPluginToolSource(await pluginRoot(validManifest())) diff --git a/packages/plugin-sdk/src/plugin-tool-source.ts b/packages/plugin-sdk/src/plugin-tool-source.ts index 8da69e0ba4..a10e54d9f7 100644 --- a/packages/plugin-sdk/src/plugin-tool-source.ts +++ b/packages/plugin-sdk/src/plugin-tool-source.ts @@ -9,6 +9,7 @@ import { type RegisteredPlugin, } from '@copse/agent/plugins/plugin-manifest.ts' import { decodeWithSchema, safeJsonParse } from '@copse/std/safe-json.ts' +import { zPluginHookRegistrations } from './plugin-tool-protocol.ts' export const PLUGIN_MANIFEST_FILE = 'copse-plugin.json' @@ -48,6 +49,7 @@ const zPluginToolSourceJson = z runtime: z.strictObject({ entrypoint: z.string().min(1).max(1_000), apiVersion: z.literal(1), + hooks: zPluginHookRegistrations.min(1).optional(), }), tools: z .strictObject({ @@ -61,7 +63,10 @@ const zPluginToolSourceJson = z .optional(), browser: zPluginBrowser.optional(), }) - .refine((value) => value.tools !== undefined || value.models !== undefined) + .refine( + (value) => + value.tools !== undefined || value.models !== undefined || value.runtime.hooks !== undefined, + ) .refine((value) => value.browser === undefined || value.models !== undefined) type PluginToolSourceJson = z.infer @@ -260,6 +265,7 @@ export async function discoverPluginToolSource( runtime: { entrypoint: relative(root, entrypoint).split(sep).join('/'), apiVersion: raw.runtime.apiVersion, + ...(raw.runtime.hooks ? { hooks: raw.runtime.hooks } : {}), }, }, { sourceHint: basename(root) }, diff --git a/packages/plugin-sdk/src/plugin-tool-worker.ts b/packages/plugin-sdk/src/plugin-tool-worker.ts index 09f2163205..5318785ac7 100644 --- a/packages/plugin-sdk/src/plugin-tool-worker.ts +++ b/packages/plugin-sdk/src/plugin-tool-worker.ts @@ -174,7 +174,8 @@ async function dispatch(line: string): Promise { } return } - case 'invoke': { + case 'invoke': + case 'invoke-hook': { if (!activated) { writeResponse(request.id, false, undefined, 'Plugin tools are not initialized.') return @@ -183,15 +184,22 @@ async function dispatch(line: string): Promise { activeInvocations.set(request.id, controller) try { const result = - request.kind === 'tool' - ? await activated.invokeTool(request.registrationId, request.input, controller.signal) - : await activated.invokeModel( + request.op === 'invoke-hook' + ? await activated.invokeHook( request.registrationId, + request.event, request.input, controller.signal, - sessionApi(request.id), - browserApi(request.id), ) + : request.kind === 'tool' + ? await activated.invokeTool(request.registrationId, request.input, controller.signal) + : await activated.invokeModel( + request.registrationId, + request.input, + controller.signal, + sessionApi(request.id), + browserApi(request.id), + ) writeResponse(request.id, true, result) } catch (err) { writeResponse(request.id, false, undefined, errorMessage(err)) diff --git a/schemas/copse-pack.schema.json b/schemas/copse-pack.schema.json index 9f618315e3..e135dfffdb 100644 --- a/schemas/copse-pack.schema.json +++ b/schemas/copse-pack.schema.json @@ -132,7 +132,42 @@ "type": "string", "minLength": 1 }, - "apiVersion": { "const": 1 } + "apiVersion": { "const": 1 }, + "hooks": { + "description": "Declared isolated function-hook registrations. IDs must be unique. Registration and explicit host invocation are supported; automatic event dispatch is not yet wired.", + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "event"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 128 }, + "event": { + "enum": [ + "turnStart", + "beforeFinalize", + "stepBoundary", + "beforeSubmitPrompt", + "toolGate", + "afterFileEdit", + "stop", + "afterToolUse", + "subagentStart", + "subagentStop", + "sessionStart", + "compaction", + "permissionDecision", + "beforeDiffApply", + "afterDiffApply", + "postTurnReview", + "modelSelected" + ] + } + } + } + } } }, "hooks": { diff --git a/src/main/services/agent-service.test.ts b/src/main/services/agent-service.test.ts index abdbea1f61..3204da41c1 100644 --- a/src/main/services/agent-service.test.ts +++ b/src/main/services/agent-service.test.ts @@ -105,6 +105,7 @@ describe('runAgent AgentHost decoupling', () => { isRunning: (pluginId) => pluginId === 'personal.reference-model', registrations: () => ({ tools: [], models: [{ id: 'judge:default' }] }), invokeTool: () => Promise.reject(new Error('not a tool turn')), + invokeHook: () => Promise.reject(new Error('not a hook dispatch')), invokeModel: (_pluginId, _routeId, input) => { invocation = input return Promise.resolve({ text: 'Personal judge answer', inputTokens: 12, outputTokens: 4 }) diff --git a/src/main/services/plugins/plugin-hook-worker.test.ts b/src/main/services/plugins/plugin-hook-worker.test.ts new file mode 100644 index 0000000000..4ad717f828 --- /dev/null +++ b/src/main/services/plugins/plugin-hook-worker.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { after, afterEach, before, describe, it } from 'node:test' +import { build } from 'esbuild' +import { PluginToolHost } from './plugin-tool-host.ts' +import { discoverPluginToolSource } from './plugin-tool-source.ts' +import type { PluginHookRegistration } from './plugin-tool-protocol.ts' + +const hooks: PluginHookRegistration[] = [ + { id: 'inspect', event: 'turnStart' }, + { id: 'wait', event: 'stop' }, + { id: 'fail', event: 'afterToolUse' }, + { id: 'invalid', event: 'stop' }, +] +const moduleSource = `export function activate(api) { + api.registerHook({ id: 'inspect', event: 'turnStart' }, (input, context) => ({ + input, event: context.event, capabilities: Object.keys(context).sort() + })); + api.registerHook({ id: 'wait', event: 'stop' }, (_input, { signal }) => new Promise(resolve => { + if (signal.aborted) resolve(null); + else signal.addEventListener('abort', () => resolve(null), { once: true }); + })); + api.registerHook({ id: 'fail', event: 'afterToolUse' }, () => { throw new Error('hook failed'); }); + api.registerHook({ id: 'invalid', event: 'stop' }, () => 1n); +}` + +let root = '' +const hosts: PluginToolHost[] = [] +let fixtureIndex = 0 + +before(async () => { + root = await mkdtemp(join(tmpdir(), 'copse-hook-worker-')) + await build({ + entryPoints: [resolve('packages/plugin-sdk/src/plugin-tool-worker.ts')], + outfile: join(root, 'worker.mjs'), + bundle: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + }) +}) +afterEach(async () => { + await Promise.all(hosts.splice(0).map((host) => host.stop())) +}) +after(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function start( + declarations: readonly PluginHookRegistration[] = hooks, +): Promise { + const pluginRoot = join(root, `plugin-${String(fixtureIndex++)}`) + await mkdir(pluginRoot) + await writeFile(join(pluginRoot, 'index.mjs'), moduleSource) + await writeFile( + join(pluginRoot, 'copse-plugin.json'), + JSON.stringify({ + name: 'personal.hook-test', + runtime: { entrypoint: 'index.mjs', apiVersion: 1, hooks: declarations }, + }), + ) + const candidate = await discoverPluginToolSource(pluginRoot) + // Exercise the real SDK, worker, framing and host. OS containment is a separate + // platform test; this injected spawn keeps this protocol test portable. + const host = await PluginToolHost.start(candidate, { + sandboxAvailable: () => true, + materialize: async (value) => value, + spawn: async () => spawn(process.execPath, [join(root, 'worker.mjs')], { stdio: 'pipe' }), + browserService: null, + }) + hosts.push(host) + return host +} + +describe('external hook worker round trip', () => { + it('registers a hook-only module and invokes the requested event without host capabilities', async () => { + const host = await start() + assert.deepEqual(host.registrations, { tools: [], models: [], hooks }) + assert.deepEqual(await host.invokeHook('inspect', 'turnStart', { userText: 'hello' }), { + input: { userText: 'hello' }, + event: 'turnStart', + capabilities: ['event', 'signal'], + }) + await assert.rejects(host.invokeHook('inspect', 'stop', {}), /event mismatch/) + await assert.rejects(host.invokeHook('missing', 'stop', {}), /Unknown plugin hook/) + }) + + it('propagates cancellation and failures, rejects unserializable results, and stays usable', async () => { + const host = await start() + const controller = new AbortController() + const pending = host.invokeHook('wait', 'stop', {}, controller.signal) + controller.abort() + await assert.rejects(pending, /cancelled/) + await assert.rejects(host.invokeHook('fail', 'afterToolUse', {}), /hook failed/) + await assert.rejects(host.invokeHook('invalid', 'stop', {}), /non-serializable/) + assert.ok(await host.invokeHook('inspect', 'turnStart', {})) + await host.stop() + await assert.rejects(host.invokeHook('inspect', 'turnStart', {}), /not running/) + }) + + it('fails startup when the worker adds, omits, or changes a declared hook', async () => { + await assert.rejects(start([{ id: 'inspect', event: 'turnStart' }]), /hooks not declared/) + await assert.rejects(start([...hooks, { id: 'missing', event: 'stop' }]), /hooks not declared/) + await assert.rejects(start(hooks.map((h) => ({ ...h, event: 'stop' }))), /hooks not declared/) + }) +}) diff --git a/src/main/services/plugins/plugin-service.test.ts b/src/main/services/plugins/plugin-service.test.ts index 8ba27ac041..66c4a907a0 100644 --- a/src/main/services/plugins/plugin-service.test.ts +++ b/src/main/services/plugins/plugin-service.test.ts @@ -282,6 +282,7 @@ describe('PluginService', () => { registrations: () => null, invokeTool: () => Promise.resolve(null), invokeModel: () => Promise.resolve(null), + invokeHook: () => Promise.resolve(null), } setPluginToolRuntimeController(controller) const registry = makeRegistry() diff --git a/src/main/services/plugins/plugin-tool-controller.test.ts b/src/main/services/plugins/plugin-tool-controller.test.ts index e4d71d3315..4dca7ae8ab 100644 --- a/src/main/services/plugins/plugin-tool-controller.test.ts +++ b/src/main/services/plugins/plugin-tool-controller.test.ts @@ -68,6 +68,7 @@ function fakeRuntime(registrations: PluginToolRegistrations): PluginToolRuntimeC invokeTool: (_pluginId, _registrationId, input) => Promise.resolve({ result: JSON.stringify(input) }), invokeModel: (_pluginId, _registrationId, input) => Promise.resolve(input), + invokeHook: (_pluginId, _registrationId, _event, input) => Promise.resolve(input), } } @@ -77,6 +78,19 @@ afterEach(async () => { }) describe('ToolingPluginToolRuntimeController', () => { + it('stops a worker with undeclared hook registrations before exposing its tools', async () => { + const plugin = await candidate() + const runtime = fakeRuntime({ + tools: [{ name: 'personal_judge', description: 'Judge', inputSchema: {} }], + models: [], + hooks: [{ id: 'unexpected', event: 'toolGate' }], + }) + const registry = new ToolRegistry() + const controller = new ToolingPluginToolRuntimeController(registry, runtime) + await assert.rejects(controller.enable(plugin), /hooks not declared/) + assert.equal(registry.has('personal_judge'), false) + assert.deepEqual(runtime.disables, ['personal.controller-test']) + }) it('registers exact declared tools, invokes them, and unregisters on disable', async () => { const plugin = await candidate() const runtime = fakeRuntime({ diff --git a/src/main/services/plugins/plugin-tool-controller.ts b/src/main/services/plugins/plugin-tool-controller.ts index b89a718684..a8a26e2f72 100644 --- a/src/main/services/plugins/plugin-tool-controller.ts +++ b/src/main/services/plugins/plugin-tool-controller.ts @@ -1,6 +1,10 @@ import type { PluginToolSourceCandidate } from './plugin-tool-source.ts' import { PluginToolHost } from './plugin-tool-host.ts' -import type { PluginToolRegistrations } from './plugin-tool-protocol.ts' +import { + validatePluginHookRegistrations, + type PluginHookRegistration, + type PluginToolRegistrations, +} from './plugin-tool-protocol.ts' import { z } from 'zod' import { defineTool } from '@shared/types' import type { ToolRegistry } from '../tool-registry.ts' @@ -10,6 +14,13 @@ export interface PluginToolRuntimeController { disable(pluginId: string): Promise isRunning(pluginId: string): boolean registrations(pluginId: string): PluginToolRegistrations | null + invokeHook( + pluginId: string, + registrationId: string, + event: PluginHookRegistration['event'], + input: unknown, + signal?: AbortSignal, + ): Promise invokeTool( pluginId: string, registrationId: string, @@ -49,6 +60,18 @@ export class DefaultPluginToolRuntimeController implements PluginToolRuntimeCont return this.hosts.get(pluginId)?.registrations ?? null } + invokeHook( + pluginId: string, + registrationId: string, + event: PluginHookRegistration['event'], + input: unknown, + signal?: AbortSignal, + ): Promise { + const host = this.hosts.get(pluginId) + if (!host) return Promise.reject(new Error(`Plugin "${pluginId}" runtime is not running.`)) + return host.invokeHook(registrationId, event, input, signal) + } + invokeTool( pluginId: string, registrationId: string, @@ -101,6 +124,10 @@ export class ToolingPluginToolRuntimeController implements PluginToolRuntimeCont try { const registrations = this.runtime.registrations(pluginId) if (!registrations) throw new Error(`Plugin "${pluginId}" returned no tool registrations.`) + validatePluginHookRegistrations( + candidate.manifest.runtime?.hooks ?? [], + registrations.hooks ?? [], + ) const declaredNames = [...(candidate.manifest.tools?.provides ?? [])].sort() const registeredNames = registrations.tools.map((tool) => tool.name).sort() if ( @@ -176,6 +203,16 @@ export class ToolingPluginToolRuntimeController implements PluginToolRuntimeCont return this.runtime.registrations(pluginId) } + invokeHook( + pluginId: string, + registrationId: string, + event: PluginHookRegistration['event'], + input: unknown, + signal?: AbortSignal, + ): Promise { + return this.runtime.invokeHook(pluginId, registrationId, event, input, signal) + } + invokeTool( pluginId: string, registrationId: string, diff --git a/src/main/services/plugins/plugin-tool-host.ts b/src/main/services/plugins/plugin-tool-host.ts index bab54fbabe..f6f8a81b30 100644 --- a/src/main/services/plugins/plugin-tool-host.ts +++ b/src/main/services/plugins/plugin-tool-host.ts @@ -22,6 +22,8 @@ import { PLUGIN_TOOL_PROTOCOL_MAX_LINE_BYTES, zPluginModelTurn, zPluginToolRegistrations, + validatePluginHookRegistrations, + type PluginHookRegistration, zPluginToolWorkerMessage, type PluginToolRegistrations, type PluginBrowserCall, @@ -199,6 +201,10 @@ export class PluginToolHost { INITIALIZE_TIMEOUT_MS, ) host.registrations = zPluginToolRegistrations.parse(result) + validatePluginHookRegistrations( + snapshot.manifest.runtime?.hooks ?? [], + host.registrations.hooks ?? [], + ) host.initialized = true host.startupStderr = '' return host @@ -501,6 +507,23 @@ export class PluginToolHost { ) } + invokeHook( + registrationId: string, + event: PluginHookRegistration['event'], + input: unknown, + signal?: AbortSignal, + ): Promise { + const hook = this.registrations.hooks?.find((h) => h.id === registrationId) + if (!hook || hook.event !== event) { + return Promise.reject(new Error(`Unknown plugin hook or event mismatch: ${registrationId}`)) + } + return this.request( + { op: 'invoke-hook', registrationId, event, input }, + INVOCATION_TIMEOUT_MS, + { ...(signal ? { signal } : {}) }, + ) + } + invokeModel(registrationId: string, input: unknown, signal?: AbortSignal): Promise { const turn = zPluginModelTurn.parse(input) return this.request( diff --git a/tests/e2e/plugin-hook-loading.e2e.ts b/tests/e2e/plugin-hook-loading.e2e.ts new file mode 100644 index 0000000000..0ca31cf11c --- /dev/null +++ b/tests/e2e/plugin-hook-loading.e2e.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { $, browser } from '@wdio/globals' +import { resetUserData, seedEmptyProject } from './helpers/seed-config.ts' +import { E2E_SCREENSHOT_DIR, saveElementScreenshot } from './helpers/screenshot.ts' + +const PLUGIN_ID = 'personal.hook-loading' +const ROW = `.plugin-row[data-plugin-id="${PLUGIN_ID}"]` + +// This intentionally uses the built app and its default runtime controller, +// snapshot materializer, worker bundle and OS sandbox. No runtime dependency is +// replaced. Hook invocation is covered separately by plugin-hook-worker.test.ts; +// automatic event dispatch is not part of the registration-only SDK contract. +describe('external hook loading through Electron Settings', function () { + this.timeout(90_000) + let root = '' + let outsideFile = '' + + before(async function () { + if (process.platform !== 'darwin') this.skip() + resetUserData() + mkdirSync(E2E_SCREENSHOT_DIR, { recursive: true }) + root = mkdtempSync(join(tmpdir(), 'copse-e2e-hook-loading-')) + const pluginRoot = join(root, 'plugin') + const workspaceRoot = join(root, 'workspace') + mkdirSync(pluginRoot) + mkdirSync(workspaceRoot) + outsideFile = join(root, 'outside.txt') + writeFileSync(outsideFile, 'outside sentinel') + writeFileSync(join(pluginRoot, 'helper.mjs'), 'export const value = "snapshot asset"\n') + writeFileSync(join(pluginRoot, 'asset.txt'), 'snapshot asset') + writeFileSync( + join(pluginRoot, 'index.mjs'), + `import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { value } from './helper.mjs'; +async function mustBeDenied(operation) { + try { await operation(); } + catch (error) { + if (error.code === 'EPERM' || error.code === 'EACCES') return; + throw error; + } + throw new Error('Production plugin sandbox allowed a forbidden operation'); +} +export async function activate(api) { + if (!fileURLToPath(import.meta.url).includes('/plugin-tool-snapshots/')) { + throw new Error('Plugin did not load from the production snapshot'); + } + if (await readFile(new URL('./asset.txt', import.meta.url), 'utf8') !== value) { + throw new Error('Snapshot asset or sibling module failed to load'); + } + await mustBeDenied(() => readFile(${JSON.stringify(outsideFile)}, 'utf8')); + await mustBeDenied(() => writeFile(${JSON.stringify(join(workspaceRoot, 'forbidden.txt'))}, 'escape')); + await mustBeDenied(() => writeFile(new URL('./asset.txt', import.meta.url), 'modified')); + api.registerHook({ id: 'inspect', event: 'turnStart' }, input => input); +} +`, + ) + writeFileSync( + join(pluginRoot, 'copse-plugin.json'), + JSON.stringify({ + name: PLUGIN_ID, + version: '0.1.0', + description: 'Hook loading and sandbox validation.', + runtime: { + entrypoint: 'index.mjs', + apiVersion: 1, + hooks: [{ id: 'inspect', event: 'turnStart' }], + }, + }), + ) + seedEmptyProject(workspaceRoot, 'e2e-hook-loading', { pluginSources: [pluginRoot] }) + await browser.reloadSession() + }) + + after(() => { + if (!root) return + resetUserData() + rmSync(root, { recursive: true, force: true }) + }) + + async function openPluginSettings(): Promise { + await $('.prompt-input').waitForExist({ timeout: 30_000 }) + await $('[aria-label="Settings"]').click() + await $('#settings-dialog button[data-section="customise"]').click() + await $(ROW).waitForExist({ timeout: 15_000 }) + } + + it('loads a hook-only snapshot under seatbelt, re-enables it and restores it after relaunch', async () => { + await openPluginSettings() + // Enabled is set only after the worker imports activate(), the sandbox + // probes succeed and the host validates the exact hook handshake. + assert.equal(await $(ROW).getAttribute('data-enabled'), 'true', await $(ROW).getText()) + await $(ROW).$('label.plugin-toggle').click() + await browser.waitUntil(async () => (await $(ROW).getAttribute('data-enabled')) === 'false') + await $(ROW).$('label.plugin-toggle').click() + await browser.waitUntil(async () => (await $(ROW).getAttribute('data-enabled')) === 'true') + assert.equal(readFileSync(outsideFile, 'utf8'), 'outside sentinel') + assert.equal(existsSync(join(root, 'workspace', 'forbidden.txt')), false) + + await browser.reloadSession() + await openPluginSettings() + assert.equal(await $(ROW).getAttribute('data-enabled'), 'true', await $(ROW).getText()) + await $(ROW).scrollIntoView() + await saveElementScreenshot(ROW, 'plugin-hook-loading.png') + }) +}) diff --git a/tests/e2e/screenshots/mcp-tool-labels.png b/tests/e2e/screenshots/mcp-tool-labels.png index 8e66179876..bfd3ee938e 100644 Binary files a/tests/e2e/screenshots/mcp-tool-labels.png and b/tests/e2e/screenshots/mcp-tool-labels.png differ diff --git a/tests/e2e/screenshots/panel-position-side.png b/tests/e2e/screenshots/panel-position-side.png index 53c848b59a..ed0423b01e 100644 Binary files a/tests/e2e/screenshots/panel-position-side.png and b/tests/e2e/screenshots/panel-position-side.png differ diff --git a/tests/e2e/screenshots/plugin-hook-loading.png b/tests/e2e/screenshots/plugin-hook-loading.png new file mode 100644 index 0000000000..2778362934 Binary files /dev/null and b/tests/e2e/screenshots/plugin-hook-loading.png differ diff --git a/tests/e2e/screenshots/portrait-panel-controls-titlebar.png b/tests/e2e/screenshots/portrait-panel-controls-titlebar.png index d272354400..84777a49f7 100644 Binary files a/tests/e2e/screenshots/portrait-panel-controls-titlebar.png and b/tests/e2e/screenshots/portrait-panel-controls-titlebar.png differ diff --git a/tests/e2e/screenshots/settings-usage-frontier-tooltip.png b/tests/e2e/screenshots/settings-usage-frontier-tooltip.png index a1cd7a6817..454e8b3ad7 100644 Binary files a/tests/e2e/screenshots/settings-usage-frontier-tooltip.png and b/tests/e2e/screenshots/settings-usage-frontier-tooltip.png differ diff --git a/tests/e2e/screenshots/supervised-tasks-waiting.png b/tests/e2e/screenshots/supervised-tasks-waiting.png index b29e9899cb..7e078df1b2 100644 Binary files a/tests/e2e/screenshots/supervised-tasks-waiting.png and b/tests/e2e/screenshots/supervised-tasks-waiting.png differ diff --git a/tests/e2e/screenshots/tool-display-rollup-collapsed.png b/tests/e2e/screenshots/tool-display-rollup-collapsed.png index 13ef498187..5f8400be2b 100644 Binary files a/tests/e2e/screenshots/tool-display-rollup-collapsed.png and b/tests/e2e/screenshots/tool-display-rollup-collapsed.png differ diff --git a/tests/e2e/screenshots/tool-display-rollup-expanded.png b/tests/e2e/screenshots/tool-display-rollup-expanded.png index 9fdc036eb1..9437160f9c 100644 Binary files a/tests/e2e/screenshots/tool-display-rollup-expanded.png and b/tests/e2e/screenshots/tool-display-rollup-expanded.png differ