From 5e76b5a742d6479cc2961ef09425baadfbdea4d5 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Wed, 26 Aug 2026 02:34:33 +0800
Subject: [PATCH] fix(hooks): reload mutable handlers after restored-mtime
edits (#128477)
---
src/hooks/import-url.test.ts | 47 ++++++++++++++++++++++++++++++------
src/hooks/import-url.ts | 6 ++---
2 files changed, 42 insertions(+), 11 deletions(-)
diff --git a/src/hooks/import-url.test.ts b/src/hooks/import-url.test.ts
index ceb2ed64948b..01bf42a7d2b1 100644
--- a/src/hooks/import-url.test.ts
+++ b/src/hooks/import-url.test.ts
@@ -2,7 +2,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { buildImportUrl } from "./import-url.js";
describe("buildImportUrl", () => {
@@ -25,23 +25,24 @@ describe("buildImportUrl", () => {
expect(url).toMatch(/^file:\/\//);
});
- it("appends mtime-based cache buster for workspace hooks", () => {
+ it("appends file-metadata cache buster for workspace hooks", () => {
const url = buildImportUrl(tmpFile, "openclaw-workspace");
- expect(url).toMatch(/\?t=[\d.]+&s=\d+/);
+ expect(url).toMatch(/\?t=[\d.]+&c=[\d.]+&s=\d+/);
- const { mtimeMs, size } = fs.statSync(tmpFile);
+ const { ctimeMs, mtimeMs, size } = fs.statSync(tmpFile);
expect(url).toContain(`?t=${mtimeMs}`);
+ expect(url).toContain(`&c=${ctimeMs}`);
expect(url).toContain(`&s=${size}`);
});
- it("appends mtime-based cache buster for managed hooks", () => {
+ it("appends file-metadata cache buster for managed hooks", () => {
const url = buildImportUrl(tmpFile, "openclaw-managed");
- expect(url).toMatch(/\?t=[\d.]+&s=\d+/);
+ expect(url).toMatch(/\?t=[\d.]+&c=[\d.]+&s=\d+/);
});
- it("appends mtime-based cache buster for plugin hooks", () => {
+ it("appends file-metadata cache buster for plugin hooks", () => {
const url = buildImportUrl(tmpFile, "openclaw-plugin");
- expect(url).toMatch(/\?t=[\d.]+&s=\d+/);
+ expect(url).toMatch(/\?t=[\d.]+&c=[\d.]+&s=\d+/);
});
it("returns same URL for bundled hooks across calls (cacheable)", () => {
@@ -56,6 +57,36 @@ describe("buildImportUrl", () => {
expect(url1).toBe(url2);
});
+ it("reloads a workspace hook after a same-size edit with restored mtime", async () => {
+ const initialSource = 'export default () => "before";\n';
+ const editedSource = 'export default () => "after!";\n';
+ expect(Buffer.byteLength(editedSource)).toBe(Buffer.byteLength(initialSource));
+
+ fs.writeFileSync(tmpFile, initialSource);
+ const cleanTime = new Date(Math.floor(Date.now() / 1000) * 1000);
+ fs.utimesSync(tmpFile, cleanTime, cleanTime);
+ const initialStat = fs.statSync(tmpFile);
+ const initialUrl = buildImportUrl(tmpFile, "openclaw-workspace");
+ const initialHandler = (await import(/* @vite-ignore */ initialUrl)).default as () => string;
+ expect(initialHandler()).toBe("before");
+
+ await vi.waitFor(
+ () => {
+ fs.writeFileSync(tmpFile, editedSource);
+ fs.utimesSync(tmpFile, initialStat.atime, initialStat.mtime);
+ expect(fs.statSync(tmpFile).ctimeMs).not.toBe(initialStat.ctimeMs);
+ },
+ { interval: 1, timeout: 1_000 },
+ );
+
+ const editedStat = fs.statSync(tmpFile);
+ expect(editedStat.size).toBe(initialStat.size);
+ expect(editedStat.mtimeMs).toBe(initialStat.mtimeMs);
+ const editedUrl = buildImportUrl(tmpFile, "openclaw-workspace");
+ const editedHandler = (await import(/* @vite-ignore */ editedUrl)).default as () => string;
+ expect(editedHandler()).toBe("after!");
+ });
+
it("falls back to Date.now() when file does not exist", () => {
const url = buildImportUrl("/nonexistent/handler.js", "openclaw-workspace");
expect(url).toMatch(/\?t=\d+/);
diff --git a/src/hooks/import-url.ts b/src/hooks/import-url.ts
index 16c5a40e6303..883879a79db9 100644
--- a/src/hooks/import-url.ts
+++ b/src/hooks/import-url.ts
@@ -6,7 +6,7 @@
* module cache across gateway restarts.
*
* Workspace, managed, and plugin hooks may be edited by the user between
- * restarts. For those we append `?t=&s=` so the module key
+ * restarts. For those we append `?t=&c=&s=` so the module key
* reflects on-disk changes while staying stable for unchanged files.
*/
@@ -29,8 +29,8 @@ export function buildImportUrl(handlerPath: string, source: HookSource): string
// Use file metadata so the cache key only changes when the file changes
try {
- const { mtimeMs, size } = fs.statSync(handlerPath);
- return `${base}?t=${mtimeMs}&s=${size}`;
+ const { ctimeMs, mtimeMs, size } = fs.statSync(handlerPath);
+ return `${base}?t=${mtimeMs}&c=${ctimeMs}&s=${size}`;
} catch {
// If stat fails (unlikely), fall back to Date.now() to guarantee freshness
return `${base}?t=${Date.now()}`;