fix(plugins): preserve mutable hook context (#97281)

* fix(plugins): preserve mutable hook context

* fix(plugins): satisfy hook context lint
This commit is contained in:
黄伟浩
2026-06-29 03:28:30 +08:00
committed by GitHub
parent f527def0a9
commit 35fcfe85cd
2 changed files with 174 additions and 3 deletions

View File

@@ -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({

View File

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