mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
fix: prefer mounted bundled plugin sources
This commit is contained in:
parent
8a52c7b3d9
commit
74a4ff1adc
8 changed files with 279 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
109
src/plugins/bundled-source-overlays.ts
Normal file
109
src/plugins/bundled-source-overlays.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue