diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b2ca85f6c..d1a99a0efdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai - Agents/Anthropic failover: treat Anthropic `api_error` payloads with `An unexpected error occurred while processing the response` as transient so retry/fallback can engage instead of surfacing a terminal failure. (#57441) Thanks @zijiess and @vincentkoc. - Agents/compaction: keep late compaction-retry rejections handled after the aggregate timeout path wins without swallowing real pre-timeout wait failures, so timed-out retries no longer surface an unhandled rejection on later unsubscribe. (#57451) Thanks @mpz4life and @vincentkoc. - Matrix/delivery recovery: treat Synapse `User not in room` replay failures as permanent during startup recovery so poisoned queued messages move to `failed/` instead of crash-looping Matrix after restart. (#57426) thanks @dlardo. +- Plugins/facades: guard bundled plugin facade loads with a cache-first sentinel so circular re-entry stops crashing `xai`, `sglang`, and `vllm` during gateway plugin startup. (#57508) Thanks @openperf. ## 2026.3.28 diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index f95a4771d98..22b40582b94 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -31,6 +31,36 @@ function createThrowingPluginDir(prefix: string): string { return rootDir; } +function createCircularPluginDir(prefix: string): string { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(rootDir); + fs.mkdirSync(path.join(rootDir, "demo"), { recursive: true }); + fs.writeFileSync( + path.join(rootDir, "facade.mjs"), + [ + `import { loadBundledPluginPublicSurfaceModuleSync } from ${JSON.stringify( + new URL("./facade-runtime.js", import.meta.url).href, + )};`, + `export const marker = loadBundledPluginPublicSurfaceModuleSync({ dirName: "demo", artifactBasename: "api.js" }).marker;`, + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(rootDir, "demo", "helper.js"), + ['import { marker } from "../facade.mjs";', "export const circularMarker = marker;", ""].join( + "\n", + ), + "utf8", + ); + fs.writeFileSync( + path.join(rootDir, "demo", "api.js"), + ['import "./helper.js";', 'export const marker = "circular-ok";', ""].join("\n"), + "utf8", + ); + return rootDir; +} + afterEach(() => { vi.restoreAllMocks(); if (originalBundledPluginsDir === undefined) { @@ -79,6 +109,18 @@ describe("plugin-sdk facade runtime", () => { expect(first.marker).toBe("identity-check"); }); + it("breaks circular facade re-entry during module evaluation", () => { + const dir = createCircularPluginDir("openclaw-facade-circular-"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + + const loaded = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ + dirName: "demo", + artifactBasename: "api.js", + }); + + expect(loaded.marker).toBe("circular-ok"); + }); + it("clears the cache on load failure so retries re-execute", () => { const dir = createThrowingPluginDir("openclaw-facade-throw-"); process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir;