From da3cf1c1a8114b4af5ce09e4205cd749587a1401 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 00:50:28 +0100 Subject: [PATCH] fix: preserve bundled facade fallback semantics --- CHANGELOG.md | 1 + src/plugin-sdk/facade-loader.test.ts | 31 +++++++++++++++++++++ src/plugin-sdk/facade-loader.ts | 6 +++- src/plugin-sdk/facade-resolution-shared.ts | 19 +++++++++++-- src/plugin-sdk/facade-runtime.test.ts | 32 ++++++++++++++++++++++ src/plugin-sdk/facade-runtime.ts | 6 +++- src/plugins/bundled-dir.ts | 4 +-- 7 files changed, 92 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07fc11827a9..cdf286d9c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugin SDK: fall back from partial bundled plugin directory overrides to package source public surfaces while preserving `OPENCLAW_DISABLE_BUNDLED_PLUGINS` as a hard disable. (#72817) Thanks @serkonyc. - Agents/ACPX: stop forwarding Codex ACP timeout config controls that Codex rejects while preserving OpenClaw's run-timeout watchdog for ACP subagents. Fixes #73052. Thanks @pfrederiksen and @richa65. - Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys. - Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit. diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index 05b6a0150b4..cc9fab4c20b 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -17,6 +17,7 @@ import { const { createTempDirSync } = createPluginSdkTestHarness(); const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; const FACADE_LOADER_GLOBAL = "__openclawTestLoadBundledPluginPublicSurfaceModuleSync"; type FacadeLoaderJitiFactory = NonNullable[0]>; @@ -86,6 +87,11 @@ afterEach(() => { } else { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; } + if (originalDisableBundledPlugins === undefined) { + delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; + } else { + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = originalDisableBundledPlugins; + } }); describe("plugin-sdk facade loader", () => { @@ -108,6 +114,31 @@ describe("plugin-sdk facade loader", () => { expect(fromB.marker).toBe("override-b"); }); + it("falls back to package source surfaces when an override dir lacks a bundled plugin", () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = createTempDirSync("openclaw-facade-loader-empty-"); + + const loaded = loadBundledPluginPublicSurfaceModuleSync<{ + closeTrackedBrowserTabsForSessions: unknown; + }>({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", + }); + + expect(loaded.closeTrackedBrowserTabsForSessions).toEqual(expect.any(Function)); + }); + + it("keeps bundled facade loads disabled when bundled plugins are disabled", () => { + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1"; + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + + expect(() => + loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", + }), + ).toThrow("Unable to resolve bundled plugin public surface browser/browser-maintenance.js"); + }); + it("shares loaded facade ids with facade-runtime", () => { const dir = createBundledPluginDir("openclaw-facade-loader-ids-", "identity-check"); process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; diff --git a/src/plugin-sdk/facade-loader.ts b/src/plugin-sdk/facade-loader.ts index f658629a6c3..ae7065e5121 100644 --- a/src/plugin-sdk/facade-loader.ts +++ b/src/plugin-sdk/facade-loader.ts @@ -59,7 +59,11 @@ function createFacadeResolutionKey(params: { env?: NodeJS.ProcessEnv; }): string { const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env); - return createFacadeResolutionKeyShared({ ...params, bundledPluginsDir }); + return createFacadeResolutionKeyShared({ + ...params, + bundledPluginsDir, + ...(params.env ? { env: params.env } : {}), + }); } function resolveFacadeModuleLocationUncached(params: { diff --git a/src/plugin-sdk/facade-resolution-shared.ts b/src/plugin-sdk/facade-resolution-shared.ts index e5efef1f195..0f2db13a207 100644 --- a/src/plugin-sdk/facade-resolution-shared.ts +++ b/src/plugin-sdk/facade-resolution-shared.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { areBundledPluginsDisabled } from "../plugins/bundled-dir.js"; import { PUBLIC_SURFACE_SOURCE_EXTENSIONS, normalizeBundledPluginArtifactSubpath, @@ -22,10 +23,12 @@ export function createFacadeResolutionKey(params: { dirName: string; artifactBasename: string; bundledPluginsDir?: string | null; + env?: NodeJS.ProcessEnv; }): string { + const disabledKey = areBundledPluginsDisabled(params.env ?? process.env) ? "disabled" : "enabled"; return `${params.dirName}::${params.artifactBasename}::${ params.bundledPluginsDir ? path.resolve(params.bundledPluginsDir) : "" - }`; + }::${disabledKey}`; } export function resolveCachedFacadeModuleLocation(params: { @@ -64,6 +67,8 @@ export function resolveBundledFacadeModuleLocation(params: { bundledPluginsDir?: string | null; }): FacadeModuleLocationLike | null { const preferSource = !params.currentModulePath.includes(`${path.sep}dist${path.sep}`); + const env = params.env ?? process.env; + const packageSourceRoot = path.resolve(params.packageRoot, "extensions"); const publicSurfaceParams = { rootDir: params.packageRoot, env: params.env, @@ -75,8 +80,16 @@ export function resolveBundledFacadeModuleLocation(params: { ? (resolveBundledPluginSourcePublicSurfacePath({ dirName: params.dirName, artifactBasename: params.artifactBasename, - sourceRoot: params.bundledPluginsDir ?? path.resolve(params.packageRoot, "extensions"), - }) ?? resolveBundledPluginPublicSurfacePath(publicSurfaceParams)) + sourceRoot: params.bundledPluginsDir ?? packageSourceRoot, + }) ?? + (params.bundledPluginsDir && !areBundledPluginsDisabled(env) + ? resolveBundledPluginSourcePublicSurfacePath({ + dirName: params.dirName, + artifactBasename: params.artifactBasename, + sourceRoot: packageSourceRoot, + }) + : null) ?? + resolveBundledPluginPublicSurfacePath(publicSurfaceParams)) : resolveBundledPluginPublicSurfacePath(publicSurfaceParams); return modulePath ? { diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index 613ab6d4ad3..b50fd09e504 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -25,6 +25,7 @@ import { const { createTempDirSync } = createPluginSdkTestHarness(); const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; +const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; const originalStateDir = process.env.OPENCLAW_STATE_DIR; function createBundledPluginDir(prefix: string, marker: string): string { @@ -48,6 +49,11 @@ afterEach(() => { } else { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir; } + if (originalDisableBundledPlugins === undefined) { + delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; + } else { + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = originalDisableBundledPlugins; + } if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { @@ -81,6 +87,32 @@ describe("plugin-sdk facade runtime", () => { }); }); + it("falls back to package source surfaces when an override dir is partial", () => { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = createTempDirSync("openclaw-facade-runtime-empty-"); + + expect( + __testing.resolveFacadeModuleLocation({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", + }), + ).toEqual({ + modulePath: path.resolve("extensions/browser/browser-maintenance.ts"), + boundaryRoot: path.resolve("."), + }); + }); + + it("does not fall back to package source surfaces when bundled plugins are disabled", () => { + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1"; + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + + expect( + __testing.resolveFacadeModuleLocation({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", + }), + ).toBeNull(); + }); + it("returns the same object identity on repeated calls (sentinel consistency)", () => { const dir = createBundledPluginDir("openclaw-facade-identity-", "identity-check"); process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; diff --git a/src/plugin-sdk/facade-runtime.ts b/src/plugin-sdk/facade-runtime.ts index 3b4bc1eebe1..bfe5f839b9b 100644 --- a/src/plugin-sdk/facade-runtime.ts +++ b/src/plugin-sdk/facade-runtime.ts @@ -59,7 +59,11 @@ function createFacadeResolutionKey(params: { env?: NodeJS.ProcessEnv; }): string { const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env); - return createFacadeResolutionKeyShared({ ...params, bundledPluginsDir }); + return createFacadeResolutionKeyShared({ + ...params, + bundledPluginsDir, + ...(params.env ? { env: params.env } : {}), + }); } function resolveRegistryPluginModuleLocation(params: { diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index ecc025c1f85..aa485410481 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -8,7 +8,7 @@ import { resolveUserPath } from "../utils.js"; const DISABLED_BUNDLED_PLUGINS_DIR = path.join(os.tmpdir(), "openclaw-empty-bundled-plugins"); -function bundledPluginsDisabled(env: NodeJS.ProcessEnv): boolean { +export function areBundledPluginsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { const raw = normalizeOptionalLowercaseString(env.OPENCLAW_DISABLE_BUNDLED_PLUGINS); return raw === "1" || raw === "true"; } @@ -109,7 +109,7 @@ function resolveBundledDirFromPackageRoot( } export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined { - if (bundledPluginsDisabled(env)) { + if (areBundledPluginsDisabled(env)) { return resolveDisabledBundledPluginsDir(); }