fix: prefer mounted bundled plugin sources

This commit is contained in:
Peter Steinberger 2026-04-26 11:28:29 +01:00
parent 8a52c7b3d9
commit 74a4ff1adc
No known key found for this signature in database
8 changed files with 279 additions and 16 deletions

View file

@ -194,6 +194,12 @@ openclaw plugins list --json
`plugins list` reads the persisted local plugin registry first, with a manifest-only derived fallback when the registry is missing or invalid. It is useful for checking whether a plugin is installed, enabled, and visible to cold startup planning, but it is not a live runtime probe of an already-running Gateway process. After changing plugin code, enablement, hook policy, or `plugins.load.paths`, restart the Gateway that serves the channel before expecting new `register(api)` code or hooks to run. For remote/container deployments, verify you are restarting the actual `openclaw gateway run` child, not only a wrapper process.
</Note>
For bundled plugin work inside a packaged Docker image, bind-mount the plugin
source directory over the matching packaged source path, such as
`/app/extensions/synology-chat`. OpenClaw will discover that mounted source
overlay before `/app/dist/extensions/synology-chat`; a plain copied source
directory remains inert so normal packaged installs still use compiled dist.
For runtime hook debugging:
- `openclaw plugins inspect <id> --json` shows registered hooks and diagnostics from a module-loaded inspection pass.

View file

@ -122,22 +122,29 @@ and setup-time config writes through `openclaw-gateway` with
The setup script accepts these optional environment variables:
| Variable | Purpose |
| ------------------------------- | --------------------------------------------------------------- |
| `OPENCLAW_IMAGE` | Use a remote image instead of building locally |
| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) |
| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) |
| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) |
| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume |
| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path |
| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export |
| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today |
| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources |
| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes |
| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded |
| Variable | Purpose |
| ------------------------------------------ | --------------------------------------------------------------- |
| `OPENCLAW_IMAGE` | Use a remote image instead of building locally |
| `OPENCLAW_DOCKER_APT_PACKAGES` | Install extra apt packages during build (space-separated) |
| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) |
| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) |
| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume |
| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) |
| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path |
| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) |
| `OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS` | Disable bundled plugin source bind-mount overlays |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Shared OTLP/HTTP collector endpoint for OpenTelemetry export |
| `OTEL_EXPORTER_OTLP_*_ENDPOINT` | Signal-specific OTLP endpoints for traces, metrics, or logs |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol override. Only `http/protobuf` is supported today |
| `OTEL_SERVICE_NAME` | Service name used for OpenTelemetry resources |
| `OTEL_SEMCONV_STABILITY_OPT_IN` | Opt in to latest experimental GenAI semantic attributes |
| `OPENCLAW_OTEL_PRELOADED` | Skip starting a second OpenTelemetry SDK when one is preloaded |
Maintainers can test bundled plugin source against a packaged image by mounting
one plugin source directory over its packaged source path, for example
`OPENCLAW_EXTRA_MOUNTS=/path/to/fork/extensions/synology-chat:/app/extensions/synology-chat:ro`.
That mounted source directory overrides the matching compiled
`/app/dist/extensions/synology-chat` bundle for the same plugin id.
### Observability

View file

@ -224,6 +224,16 @@ OpenClaw scans for plugins in this order (first match wins):
</Step>
</Steps>
Packaged installs and Docker images normally resolve bundled plugins from the
compiled `dist/extensions` tree. If a bundled plugin source directory is
bind-mounted over the matching packaged source path, for example
`/app/extensions/synology-chat`, OpenClaw treats that mounted source directory
as a bundled source overlay and discovers it before the packaged
`/app/dist/extensions/synology-chat` bundle. This keeps maintainer container
loops working without switching every bundled plugin back to TypeScript source.
Set `OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS=1` to force packaged dist bundles
even when source overlay mounts are present.
### Enablement rules
- `plugins.enabled: false` disables all plugins

View file

@ -59,6 +59,11 @@ export function buildLegacyBundledPath(localPath: string): string | null {
return bundledLeaf ? path.join(packaged.packageRoot, "extensions", bundledLeaf) : null;
}
export function buildLegacyBundledRootPath(localPath: string): string | null {
const packaged = findPackagedBundledRoot(localPath);
return packaged ? path.join(packaged.packageRoot, "extensions") : null;
}
export function buildBundledPluginLoadPathAliases(localPath: string): BundledPluginLoadPathAlias[] {
const legacyPath = buildLegacyBundledPath(localPath);
if (!legacyPath) {

View file

@ -0,0 +1,109 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
function decodeMountInfoPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
export function parseLinuxMountInfoMountPoints(mountInfo: string): Set<string> {
const mountPoints = new Set<string>();
for (const line of mountInfo.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const fields = trimmed.split(" ");
const mountPoint = fields[4];
if (!mountPoint) {
continue;
}
mountPoints.add(path.resolve(decodeMountInfoPath(mountPoint)));
}
return mountPoints;
}
function readLinuxMountPoints(): Set<string> {
try {
return parseLinuxMountInfoMountPoints(fs.readFileSync("/proc/self/mountinfo", "utf8"));
} catch {
return new Set();
}
}
function isFilesystemMountPoint(targetPath: string): boolean {
try {
const target = fs.statSync(targetPath);
const parent = fs.statSync(path.dirname(targetPath));
return target.dev !== parent.dev || target.ino === parent.ino;
} catch {
return false;
}
}
function sourceOverlaysDisabled(env: NodeJS.ProcessEnv): boolean {
const raw = normalizeOptionalLowercaseString(env.OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS);
return raw === "1" || raw === "true";
}
export function isBundledSourceOverlayPath(params: {
sourcePath: string;
mountPoints?: ReadonlySet<string>;
}): boolean {
const resolved = path.resolve(params.sourcePath);
const mountPoints = params.mountPoints ?? readLinuxMountPoints();
return mountPoints.has(resolved) || isFilesystemMountPoint(resolved);
}
export function listBundledSourceOverlayDirs(params: {
bundledRoot?: string;
env?: NodeJS.ProcessEnv;
mountPoints?: ReadonlySet<string>;
}): string[] {
const env = params.env ?? process.env;
if (sourceOverlaysDisabled(env) || !params.bundledRoot) {
return [];
}
const legacyRoot = buildLegacyBundledRootPath(params.bundledRoot);
if (!legacyRoot || !fs.existsSync(legacyRoot)) {
return [];
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(legacyRoot, { withFileTypes: true });
} catch {
return [];
}
const mountPoints = params.mountPoints ?? readLinuxMountPoints();
const legacyRootMounted = isBundledSourceOverlayPath({
sourcePath: legacyRoot,
mountPoints,
});
const overlayDirs: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const sourceDir = path.join(legacyRoot, entry.name);
const bundledPeer = path.join(params.bundledRoot, entry.name);
if (!fs.existsSync(bundledPeer)) {
continue;
}
if (
!legacyRootMounted &&
!isBundledSourceOverlayPath({
sourcePath: sourceDir,
mountPoints,
})
) {
continue;
}
overlayDirs.push(sourceDir);
}
return overlayDirs.toSorted((left, right) => left.localeCompare(right));
}

View file

@ -657,6 +657,7 @@ describe("plugin-sdk subpath exports", () => {
],
pattern: /openclaw\/plugin-sdk\/channel-runtime(?=["'])/u,
exclude: [
"src/plugins/compat/registry.ts",
"src/plugins/sdk-alias.test.ts",
"src/plugins/contracts/plugin-sdk-root-alias.test.ts",
],

View file

@ -107,6 +107,20 @@ function writeStandalonePlugin(filePath: string, source = "export default functi
fs.writeFileSync(filePath, source, "utf-8");
}
function mockLinuxMountInfo(mountPoints: readonly string[]) {
const originalReadFileSync = fs.readFileSync;
return vi.spyOn(fs, "readFileSync").mockImplementation((filePath, options) => {
if (filePath === "/proc/self/mountinfo") {
return mountPoints
.map(
(mountPoint, index) => `${100 + index} 99 0:${index} / ${mountPoint} rw - tmpfs tmpfs rw`,
)
.join("\n");
}
return originalReadFileSync(filePath, options as never) as never;
});
}
function createPackagePlugin(params: {
packageDir: string;
packageName: string;
@ -453,6 +467,95 @@ describe("discoverOpenClawPlugins", () => {
]);
});
it("discovers bind-mounted bundled source overlays before packaged dist bundles", () => {
const stateDir = makeTempDir();
const packageRoot = path.join(stateDir, "node_modules", "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const bundledPluginDir = path.join(bundledRoot, "synology-chat");
const sourcePluginDir = path.join(packageRoot, "extensions", "synology-chat");
createPackagePluginWithEntry({
packageDir: bundledPluginDir,
packageName: "@openclaw/synology-chat",
pluginId: "synology-chat",
entryPath: "index.js",
});
createPackagePluginWithEntry({
packageDir: sourcePluginDir,
packageName: "@openclaw/synology-chat",
pluginId: "synology-chat",
});
mockLinuxMountInfo([sourcePluginDir]);
const sourceEntryPath = path.join(sourcePluginDir, "src", "index.ts");
const bundledEntryPath = path.join(bundledPluginDir, "index.js");
const { candidates, diagnostics } = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
},
});
const synologyCandidates = candidates.filter(
(candidate) => candidate.idHint === "synology-chat",
);
expect(synologyCandidates).toEqual([
expect.objectContaining({
origin: "bundled",
rootDir: fs.realpathSync(sourcePluginDir),
source: fs.realpathSync(sourceEntryPath),
}),
expect.objectContaining({
origin: "bundled",
rootDir: fs.realpathSync(bundledPluginDir),
source: fs.realpathSync(bundledEntryPath),
}),
]);
expect(diagnostics).toEqual([
expect.objectContaining({
level: "warn",
source: sourcePluginDir,
message: expect.stringContaining("bind-mounted bundled plugin source overlay"),
}),
]);
});
it("keeps copied source plugin dirs inert when they are not mounted overlays", () => {
const stateDir = makeTempDir();
const packageRoot = path.join(stateDir, "node_modules", "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const bundledPluginDir = path.join(bundledRoot, "synology-chat");
const sourcePluginDir = path.join(packageRoot, "extensions", "synology-chat");
createPackagePluginWithEntry({
packageDir: bundledPluginDir,
packageName: "@openclaw/synology-chat",
pluginId: "synology-chat",
entryPath: "index.js",
});
createPackagePluginWithEntry({
packageDir: sourcePluginDir,
packageName: "@openclaw/synology-chat",
pluginId: "synology-chat",
});
mockLinuxMountInfo([]);
const bundledEntryPath = path.join(bundledPluginDir, "index.js");
const { candidates, diagnostics } = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
},
});
expect(candidates.filter((candidate) => candidate.idHint === "synology-chat")).toEqual([
expect.objectContaining({
origin: "bundled",
rootDir: fs.realpathSync(bundledPluginDir),
source: fs.realpathSync(bundledEntryPath),
}),
]);
expect(diagnostics).toEqual([]);
});
it("loads package extension packs", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "pack");

View file

@ -8,6 +8,7 @@ import {
import { resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import { resolvePackagedBundledLoadPathAlias } from "./bundled-load-path-aliases.js";
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
import {
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
@ -935,6 +936,27 @@ export function discoverOpenClawPlugins(params: {
load: () => {
const result = createDiscoveryResult();
const seen = new Set<string>();
for (const sourceOverlayDir of listBundledSourceOverlayDirs({
bundledRoot: roots.stock,
env,
})) {
discoverFromPath({
rawPath: sourceOverlayDir,
origin: "bundled",
ownershipUid: params.ownershipUid,
workspaceDir,
env,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
});
result.diagnostics.push({
level: "warn",
source: sourceOverlayDir,
message:
"using bind-mounted bundled plugin source overlay; this source overrides the packaged dist bundle for the same plugin id",
});
}
if (roots.stock) {
discoverInDirectory({
dir: roots.stock,