diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index b7014fdb76e..d6365037d36 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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. +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 --json` shows registered hooks and diagnostics from a module-loaded inspection pass. diff --git a/docs/install/docker.md b/docs/install/docker.md index 50dac2d6915..24589c5e35e 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -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 diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 4191b72ec8f..40dc4378d76 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -224,6 +224,16 @@ OpenClaw scans for plugins in this order (first match wins): +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 diff --git a/src/plugins/bundled-load-path-aliases.ts b/src/plugins/bundled-load-path-aliases.ts index 3cb0b67bfb0..a49aa9e58ea 100644 --- a/src/plugins/bundled-load-path-aliases.ts +++ b/src/plugins/bundled-load-path-aliases.ts @@ -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) { diff --git a/src/plugins/bundled-source-overlays.ts b/src/plugins/bundled-source-overlays.ts new file mode 100644 index 00000000000..b8d549bc0fa --- /dev/null +++ b/src/plugins/bundled-source-overlays.ts @@ -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 { + const mountPoints = new Set(); + 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 { + 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; +}): 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[] { + 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)); +} diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 7dfbcdb9e51..0994d4ffbf7 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -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", ], diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 638123d0acd..d5e2eacb6cc 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.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"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 04d426e12f9..f0c77201e5b 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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(); + 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,