From c8ee33c162588bb8becd25bfa090b856266a932f Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Fri, 20 Feb 2026 14:44:51 -0300 Subject: [PATCH] fix(gateway): include export name in hook transform cache key (#13855) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: a9eea919b88b33c3297620d62b38bac9cfa412bf Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + src/gateway/hooks-mapping.test.ts | 66 +++++++++++++++++++++++++++++++ src/gateway/hooks-mapping.ts | 5 ++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dba04a67..4731001ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr. - Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr. - Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek. - Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) thanks @coygeek. diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index 882ab0e18..bb3b4080b 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -294,6 +294,72 @@ describe("hooks mapping", () => { } }); + it("caches transform functions by module path and export name", async () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-export-")); + const transformsRoot = path.join(configDir, "hooks", "transforms"); + fs.mkdirSync(transformsRoot, { recursive: true }); + const modPath = path.join(transformsRoot, "multi-export.mjs"); + fs.writeFileSync( + modPath, + [ + 'export function transformA() { return { kind: "wake", text: "from-A" }; }', + 'export function transformB() { return { kind: "wake", text: "from-B" }; }', + ].join("\n"), + ); + + const mappingsA = resolveHookMappings( + { + mappings: [ + { + match: { path: "testA" }, + action: "agent", + messageTemplate: "unused", + transform: { module: "multi-export.mjs", export: "transformA" }, + }, + ], + }, + { configDir }, + ); + + const mappingsB = resolveHookMappings( + { + mappings: [ + { + match: { path: "testB" }, + action: "agent", + messageTemplate: "unused", + transform: { module: "multi-export.mjs", export: "transformB" }, + }, + ], + }, + { configDir }, + ); + + const resultA = await applyHookMappings(mappingsA, { + payload: {}, + headers: {}, + url: new URL("http://127.0.0.1:18789/hooks/testA"), + path: "testA", + }); + + const resultB = await applyHookMappings(mappingsB, { + payload: {}, + headers: {}, + url: new URL("http://127.0.0.1:18789/hooks/testB"), + path: "testB", + }); + + expect(resultA?.ok).toBe(true); + if (resultA?.ok && resultA.action?.kind === "wake") { + expect(resultA.action.text).toBe("from-A"); + } + + expect(resultB?.ok).toBe(true); + if (resultB?.ok && resultB.action?.kind === "wake") { + expect(resultB.action.text).toBe("from-B"); + } + }); + it("rejects missing message", async () => { const mappings = resolveHookMappings({ mappings: [{ match: { path: "noop" }, action: "agent" }], diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index efec4e537..7b28dd88c 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -325,14 +325,15 @@ function validateAction(action: HookAction): HookMappingResult { } async function loadTransform(transform: HookMappingTransformResolved): Promise { - const cached = transformCache.get(transform.modulePath); + const cacheKey = `${transform.modulePath}::${transform.exportName ?? "default"}`; + const cached = transformCache.get(cacheKey); if (cached) { return cached; } const url = pathToFileURL(transform.modulePath).href; const mod = (await import(url)) as Record; const fn = resolveTransformFn(mod, transform.exportName); - transformCache.set(transform.modulePath, fn); + transformCache.set(cacheKey, fn); return fn; }