diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index c776bb137c3..78a5ab7f7a5 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs"; import path from "node:path"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { applyBootstrapHookOverrides } from "../agents/bootstrap-hooks.js"; import { listRegisteredAgentHarnesses } from "../agents/harness/registry.js"; +import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; import { resolveConfigEnvVars } from "../config/env-substitution.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { @@ -2973,6 +2975,163 @@ module.exports = { id: "throws-after-import", register() {} };`, clearInternalHooks(); }); + it("preserves plugin hook context mutations for bootstrap hooks", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "hook-bootstrap-mutation", + filename: "hook-bootstrap-mutation.cjs", + body: `module.exports = { + id: "hook-bootstrap-mutation", + register(api) { + api.registerHook( + "agent:bootstrap", + (event) => { + event.context.bootstrapFiles = [ + { + name: "AGENTS.md", + path: "/tmp/override-AGENTS.md", + content: "override bootstrap rules", + missing: false, + }, + ]; + }, + { name: "hook-bootstrap-mutation" }, + ); + }, + };`, + }); + + clearInternalHooks(); + + loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["hook-bootstrap-mutation"], + }, + options: { + onlyPluginIds: ["hook-bootstrap-mutation"], + }, + }); + + const updated = await applyBootstrapHookOverrides({ + files: [ + { + name: "AGENTS.md", + path: "/tmp/base-AGENTS.md", + content: "base bootstrap rules", + missing: false, + } satisfies WorkspaceBootstrapFile, + ], + workspaceDir: "/tmp", + sessionKey: "agent:main:subagent:test-bootstrap", + }); + + expect(updated).toEqual([ + { + name: "AGENTS.md", + path: "/tmp/override-AGENTS.md", + content: "override bootstrap rules", + missing: false, + }, + ]); + + clearInternalHooks(); + }); + + it("runs consecutive plugin hook handlers with shared mutable context but isolated plugin config", async () => { + useNoBundledPlugins(); + const first = writePlugin({ + id: "hook-context-first", + filename: "hook-context-first.cjs", + body: `module.exports = { + id: "hook-context-first", + register(api) { + api.registerHook( + "gateway:startup", + (event) => { + event.messages.push("first-config=" + event.context.pluginConfig?.marker); + event.context.note = "mutation-from-first"; + }, + { name: "hook-context-first" }, + ); + }, + };`, + }); + const second = writePlugin({ + id: "hook-context-second", + filename: "hook-context-second.cjs", + body: `module.exports = { + id: "hook-context-second", + register(api) { + api.registerHook( + "gateway:startup", + (event) => { + event.messages.push( + "second-config=" + String(event.context.pluginConfig?.marker ?? "none"), + ); + event.messages.push("note=" + String(event.context.note ?? "missing-note")); + }, + { name: "hook-context-second" }, + ); + }, + };`, + }); + for (const plugin of [first, second]) { + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: plugin.id, + configSchema: { type: "object" }, + }, + null, + 2, + ), + "utf-8", + ); + } + + clearInternalHooks(); + + loadOpenClawPlugins({ + cache: false, + workspaceDir: first.dir, + onlyPluginIds: ["hook-context-first", "hook-context-second"], + config: { + plugins: { + load: { paths: [first.file, second.file] }, + allow: ["hook-context-first", "hook-context-second"], + entries: { + "hook-context-first": { + config: { + marker: "visible-to-first", + }, + }, + "hook-context-second": { + config: { + marker: "visible-to-second", + }, + }, + }, + }, + }, + }); + + const event = createInternalHookEvent("gateway", "startup", "gateway:startup"); + await triggerInternalHook(event); + + expect(event.messages).toEqual([ + "first-config=visible-to-first", + "second-config=visible-to-second", + "note=mutation-from-first", + ]); + expect(event.context).toEqual({ + note: "mutation-from-first", + }); + + clearInternalHooks(); + }); + it("rolls back global side effects when registration fails", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index aeb7f8c4531..2bb6f21e74e 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -741,9 +741,21 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }> = []; for (const event of normalizedEvents) { const wrappedHandler: typeof handler = async (evt) => { - // Shallow-copy to avoid mutating the shared event object - // passed to all handlers sequentially by triggerInternalHook - return handler({ ...evt, context: { ...evt.context, pluginConfig } }); + const context = evt.context; + const hadPluginConfig = Object.hasOwn(context, "pluginConfig"); + const previousPluginConfig = context.pluginConfig; + // Internal hooks intentionally share one mutable context object across + // handlers; only pluginConfig stays per-handler and is restored after. + context.pluginConfig = pluginConfig; + try { + return await handler({ ...evt, context }); + } finally { + if (hadPluginConfig) { + context.pluginConfig = previousPluginConfig; + } else { + delete context.pluginConfig; + } + } }; registerInternalHook(event, wrappedHandler); nextRegistrations.push({ event, handler: wrappedHandler });