Files
openclaw/src/plugins/plugin-runtime-artifact-identity.ts
Peter Steinberger 9ff855db05 fix(plugins): canonicalize plugin entry path per process to prevent duplicate register() (#108508)
On git-channel installs where both extensions/<id>/index.ts and
dist/extensions/<id>/index.js exist, two registry assemblies resolved the same
plugin id to different physical files (preferBuiltPluginArtifacts drives both the
artifact choice and the load-cache key), so the path-keyed module cache
evaluated the entry twice — two live plugin instances with separate state,
binding hooks to one and registerTool to the other. Memoize the first resolved
runtime entry path per plugin-id + real root + entry-kind so both assemblies
converge on one module instance; clear the memo on activated-runtime-state and
registry-load-cache resets so reload/install/doctor re-resolve. No-op on
dist-only installs.

Closes #107933
2026-07-15 21:58:13 -07:00

190 lines
6.6 KiB
TypeScript

/** Computes a bounded content identity for plugin-owned runtime artifacts. */
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { openRootFileSync } from "../infra/boundary-file-read.js";
import { walkDirectorySync } from "../infra/fs-safe.js";
import type { OpenClawPackageBuild } from "./manifest.js";
import { safeRealpathSync } from "./path-safety.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { resolvePluginRuntimeArtifact } from "./plugin-runtime-artifact-resolution.js";
const MAX_RUNTIME_ARTIFACT_DEPTH = 64;
const MAX_RUNTIME_ARTIFACT_ENTRIES = 50_000;
const MAX_RUNTIME_ARTIFACT_FILE_BYTES = 256 * 1024 * 1024;
const MAX_RUNTIME_ARTIFACT_TOTAL_BYTES = 512 * 1024 * 1024;
const READ_CHUNK_BYTES = 64 * 1024;
const EXCLUDED_RUNTIME_ARTIFACT_DIRECTORIES = new Set([".git", ".hg", ".svn", "node_modules"]);
export type PluginRuntimeArtifactIdentitySource = Readonly<{
pluginId: string;
origin: PluginOrigin;
rootDir: string;
source?: string;
packageBuild?: OpenClawPackageBuild;
}>;
function normalizeRelativePath(filePath: string): string {
return filePath.split(path.sep).join("/");
}
function listRuntimeArtifactFiles(rootDir: string): string[] {
const scan = walkDirectorySync(rootDir, {
maxDepth: MAX_RUNTIME_ARTIFACT_DEPTH,
maxEntries: MAX_RUNTIME_ARTIFACT_ENTRIES,
symlinks: "include",
descend: (entry) => !EXCLUDED_RUNTIME_ARTIFACT_DIRECTORIES.has(entry.name),
include: (entry) =>
entry.kind !== "directory" && !EXCLUDED_RUNTIME_ARTIFACT_DIRECTORIES.has(entry.name),
});
if (scan.truncated) {
throw new Error("plugin runtime artifact exceeds the bounded file scan");
}
if ((scan.failedDirs?.length ?? 0) > 0) {
throw new Error("plugin runtime artifact contains an unreadable directory");
}
return scan.entries
.map((entry) => {
if (entry.kind !== "file") {
throw new Error(`plugin runtime artifact contains unsupported ${entry.kind} entry`);
}
return normalizeRelativePath(entry.relativePath);
})
.toSorted();
}
function sameOpenedFile(before: fs.Stats, after: fs.Stats): boolean {
return (
before.dev === after.dev &&
before.ino === after.ino &&
before.size === after.size &&
before.mtimeMs === after.mtimeMs &&
before.ctimeMs === after.ctimeMs
);
}
function hashRuntimeArtifactFile(params: {
rootDir: string;
rootRealPath: string;
relativePath: string;
}): { hash: string; size: number; mode: number } {
const absolutePath = path.join(params.rootDir, params.relativePath);
const opened = openRootFileSync({
absolutePath,
rootPath: params.rootDir,
rootRealPath: params.rootRealPath,
boundaryLabel: "plugin runtime artifact",
maxBytes: MAX_RUNTIME_ARTIFACT_FILE_BYTES,
rejectHardlinks: false,
});
if (!opened.ok) {
throw new Error(`plugin runtime artifact file is not readable: ${params.relativePath}`);
}
try {
const hash = crypto.createHash("sha256");
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
let offset = 0;
while (offset < opened.stat.size) {
const read = fs.readSync(
opened.fd,
buffer,
0,
Math.min(buffer.length, opened.stat.size - offset),
offset,
);
if (read === 0) {
throw new Error(
`plugin runtime artifact file changed while reading: ${params.relativePath}`,
);
}
hash.update(buffer.subarray(0, read));
offset += read;
}
const after = fs.fstatSync(opened.fd);
if (!sameOpenedFile(opened.stat, after)) {
throw new Error(`plugin runtime artifact file changed while reading: ${params.relativePath}`);
}
return { hash: hash.digest("hex"), size: opened.stat.size, mode: opened.stat.mode };
} finally {
fs.closeSync(opened.fd);
}
}
/**
* Hashes plugin-owned files only. Dependency stores and VCS metadata are
* separate runtime owners; plugin installs/updates must replace this digest.
*/
export function fingerprintPluginRuntimeArtifact(
record: PluginRuntimeArtifactIdentitySource,
): string {
const runtimeArtifact = record.source
? resolvePluginRuntimeArtifact({
pluginId: record.pluginId,
entryKind: "runtime",
source: record.source,
rootDir: record.rootDir,
origin: record.origin,
// Gateway and standalone agent runtimes select built artifacts.
preferBuiltPluginArtifacts: true,
...(record.packageBuild ? { packageManifest: { build: record.packageBuild } } : {}),
})
: { rootDir: record.rootDir, source: undefined };
const rootDir = path.resolve(runtimeArtifact.rootDir);
const rootRealPath = safeRealpathSync(rootDir);
if (!rootRealPath) {
throw new Error(`plugin runtime root is unavailable: ${record.pluginId}`);
}
const source = runtimeArtifact.source
? path.isAbsolute(runtimeArtifact.source)
? runtimeArtifact.source
: path.resolve(rootRealPath, runtimeArtifact.source)
: null;
const sourceRelativePath = source
? path.relative(rootRealPath, safeRealpathSync(source) ?? source)
: null;
if (
sourceRelativePath !== null &&
(sourceRelativePath === ".." ||
sourceRelativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(sourceRelativePath))
) {
throw new Error(`plugin runtime entry escapes its root: ${record.pluginId}`);
}
const beforeFiles = listRuntimeArtifactFiles(rootRealPath);
if (
sourceRelativePath !== null &&
!beforeFiles.includes(normalizeRelativePath(sourceRelativePath))
) {
throw new Error(`plugin runtime entry is unavailable: ${record.pluginId}`);
}
const hash = crypto.createHash("sha256");
hash.update("openclaw-plugin-runtime-artifact-v1\0");
hash.update(sourceRelativePath ? normalizeRelativePath(sourceRelativePath) : "<no-source>");
hash.update("\0");
let totalBytes = 0;
for (const relativePath of beforeFiles) {
const file = hashRuntimeArtifactFile({ rootDir: rootRealPath, rootRealPath, relativePath });
totalBytes += file.size;
if (totalBytes > MAX_RUNTIME_ARTIFACT_TOTAL_BYTES) {
throw new Error("plugin runtime artifact exceeds the bounded content scan");
}
hash.update(relativePath);
hash.update("\0");
hash.update(String(file.mode));
hash.update("\0");
hash.update(String(file.size));
hash.update("\0");
hash.update(file.hash);
hash.update("\0");
}
const afterFiles = listRuntimeArtifactFiles(rootRealPath);
if (
beforeFiles.length !== afterFiles.length ||
beforeFiles.some((file, i) => file !== afterFiles[i])
) {
throw new Error("plugin runtime artifact changed while reading");
}
return hash.digest("hex");
}