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
This commit is contained in:
Marcus Castro
2026-02-20 14:44:51 -03:00
committed by GitHub
parent 618b36f07a
commit c8ee33c162
3 changed files with 70 additions and 2 deletions

View File

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

View File

@@ -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" }],

View File

@@ -325,14 +325,15 @@ function validateAction(action: HookAction): HookMappingResult {
}
async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
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<string, unknown>;
const fn = resolveTransformFn(mod, transform.exportName);
transformCache.set(transform.modulePath, fn);
transformCache.set(cacheKey, fn);
return fn;
}