chore: merge main into Talk conversation text cap

* origin/main:
  refactor(plugins): consolidate registry snapshots (#117561)
  fix(plugins): fail blocked enable commands (#117536)
  test(secrets): dedupe runtime state fixtures (#117563)
This commit is contained in:
Vincent Koc
2026-08-02 02:55:39 +08:00
10 changed files with 1062 additions and 1620 deletions
+5 -3
View File
@@ -118,7 +118,7 @@ describe("plugins cli policy mutations", () => {
plugins: { allow: ["other-plugin"] },
reason: "blocked by allowlist",
},
])("does not mutate plugin state when $policy blocks enablement", async ({ plugins, reason }) => {
])("fails without mutations when $policy blocks enablement", async ({ plugins, reason }) => {
const sourceConfig = { plugins } as OpenClawConfig;
loadConfig.mockReturnValue(sourceConfig);
enablePluginInConfig.mockReturnValue({
@@ -129,11 +129,13 @@ describe("plugins cli policy mutations", () => {
});
mockPluginRegistry(["alpha"]);
await runPluginsCommand(["plugins", "enable", "alpha"]);
await expect(runPluginsCommand(["plugins", "enable", "alpha"])).rejects.toThrow("__exit__:1");
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expect(runtimeLogs).toContain(`Plugin "alpha" could not be enabled (${reason}).`);
expect(runtimeErrors).toContain(`Plugin "alpha" could not be enabled (${reason}).`);
expect(runtimeLogs).not.toContain(`Plugin "alpha" could not be enabled (${reason}).`);
});
it("refuses plugin enablement in Nix mode before config mutation", async () => {
+3 -5
View File
@@ -205,12 +205,10 @@ async function runPluginsEnableCommandUnlocked(idInput: string): Promise<void> {
});
// A blocked request must not displace the active slot or rewrite persisted state.
if (!enableResult.enabled) {
defaultRuntime.log(
theme.warn(
`Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`,
),
defaultRuntime.error(
`Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`,
);
return;
return defaultRuntime.exit(1);
}
const { applySlotSelectionForPlugin } = await loadPluginSlotSelection();
@@ -59,25 +59,3 @@ export function safeFileSignature(filePath: string): InstalledPluginFileSignatur
return undefined;
}
}
/** Compares current file metadata with a stored installed-plugin file signature. */
export function fileSignatureMatches(
filePath: string,
signature: InstalledPluginFileSignature | undefined,
): boolean | undefined {
if (!signature) {
return undefined;
}
if (typeof signature.ctimeMs !== "number") {
return undefined;
}
const current = safeFileSignature(filePath);
if (!current) {
return false;
}
return (
current.size === signature.size &&
current.mtimeMs === signature.mtimeMs &&
current.ctimeMs === signature.ctimeMs
);
}
@@ -1,5 +1,6 @@
// Verifies current plugin registry contribution snapshots.
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js";
@@ -8,6 +9,7 @@ import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js";
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js";
afterEach(() => {
clearCurrentPluginMetadataSnapshot();
@@ -141,7 +143,7 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => {
expect(loadPluginManifestRegistryForPluginRegistry({ config, env }).plugins).toEqual([]);
});
it("does not reuse current metadata for explicit registry inputs or diagnostics", () => {
it("keeps explicit registry inputs authoritative and reuses current diagnostics", () => {
const config: OpenClawConfig = {};
const env = {
HOME: "/tmp/openclaw-test-home",
@@ -190,11 +192,26 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => {
}),
{ config, env, workspaceDir },
);
const readDirectory = vi.spyOn(fs, "readdirSync");
const readFile = vi.spyOn(fs, "readFileSync");
const statFile = vi.spyOn(fs, "statSync");
expect(
loadPluginManifestRegistryForPluginRegistry({ config, env, workspaceDir }).plugins.map(
(plugin) => plugin.id,
),
).toEqual([]);
).toEqual(["enabled"]);
expect(
loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }).diagnostics,
).toEqual([
{
level: "info",
code: "persisted-registry-missing",
message: "missing",
},
]);
expect(readDirectory).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(statFile).not.toHaveBeenCalled();
});
});
@@ -1,24 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getCurrentPluginMetadataSnapshotState,
setCurrentPluginMetadataSnapshotState,
} from "./current-plugin-metadata-state.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import "./plugin-registry-snapshot.js";
vi.mock("./current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: vi.fn(() => undefined),
}));
afterEach(() => {
clearPluginMetadataLifecycleCaches();
});
describe("plugin registry snapshot lifecycle", () => {
it("clears registry metadata when the snapshot facade is mocked", () => {
setCurrentPluginMetadataSnapshotState({ plugins: [] }, "mocked-snapshot-facade");
expect(() => clearPluginMetadataLifecycleCaches()).not.toThrow();
expect(getCurrentPluginMetadataSnapshotState().snapshot).toBeUndefined();
});
});
+224 -88
View File
@@ -16,7 +16,6 @@ import {
} from "./installed-plugin-index.js";
import { markRetainedManagedNpmInstall } from "./managed-npm-retention.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
@@ -275,7 +274,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
});
});
it("does not treat diagnostic current metadata as provided registry input", () => {
it("reuses diagnostic current metadata without promoting its registry source", () => {
const env = {
...createHermeticEnv(makeTempDir()),
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
@@ -300,6 +299,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
configFingerprint: "",
workspaceDir,
index,
registrySource: "derived",
registryDiagnostics: [
{
level: "info",
@@ -333,10 +333,27 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
},
{ config, env, workspaceDir },
);
const readDirectory = vi.spyOn(fs, "readdirSync");
const readFile = vi.spyOn(fs, "readFileSync");
const statFile = vi.spyOn(fs, "statSync");
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir });
expect(result.source).not.toBe("provided");
expect(result).toEqual({
snapshot: index,
source: "derived",
diagnostics: [
{
level: "info",
code: "persisted-registry-missing",
message: "missing",
},
],
manifestRegistry: { plugins: [], diagnostics: [] },
});
expect(readDirectory).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(statFile).not.toHaveBeenCalled();
});
it("does not reuse current metadata when explicit derivation inputs are supplied", () => {
@@ -559,75 +576,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expect(result.diagnostics).toStrictEqual([]);
});
it("reuses a memoized registry without polling plugin files", () => {
const tempRoot = makeTempDir();
const workspaceDir = path.join(tempRoot, "workspace");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {};
const first = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir });
const readDirectory = vi.spyOn(fs, "readdirSync");
const readFile = vi.spyOn(fs, "readFileSync");
const statFile = vi.spyOn(fs, "statSync");
expect(loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir })).toBe(first);
expect(readDirectory).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(statFile).not.toHaveBeenCalled();
});
it("retains only the current process-lifecycle registry graph", () => {
const tempRoot = makeTempDir();
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const firstWorkspace = path.join(tempRoot, "first-workspace");
const secondWorkspace = path.join(tempRoot, "second-workspace");
const first = loadPluginRegistrySnapshotWithMetadata({
config: {},
env,
workspaceDir: firstWorkspace,
});
const second = loadPluginRegistrySnapshotWithMetadata({
config: {},
env,
workspaceDir: secondWorkspace,
});
const refreshedFirst = loadPluginRegistrySnapshotWithMetadata({
config: {},
env,
workspaceDir: firstWorkspace,
});
expect(second).not.toBe(first);
expect(refreshedFirst).not.toBe(first);
expect(
loadPluginRegistrySnapshotWithMetadata({
config: {},
env,
workspaceDir: firstWorkspace,
}),
).toBe(refreshedFirst);
});
it("refreshes workspace plugin discovery on explicit metadata invalidation", () => {
const tempRoot = makeTempDir();
const workspaceDir = path.join(tempRoot, "workspace");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const first = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir });
expect(first.snapshot.plugins.map((plugin) => plugin.pluginId)).not.toContain("demo");
writePackagePlugin(path.join(workspaceDir, ".openclaw", "extensions", "demo"));
const second = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir });
expect(second).toBe(first);
clearPluginMetadataLifecycleCaches();
const refreshed = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir });
expect(refreshed.snapshot.plugins.map((plugin) => plugin.pluginId)).toContain("demo");
});
it("ignores malformed load paths while memoizing snapshots", () => {
it("ignores malformed load paths while deriving snapshots", () => {
const tempRoot = makeTempDir();
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
@@ -673,6 +622,36 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expect(result.diagnostics).toStrictEqual([]);
});
it("rebuilds when an explicit candidate moves identical package metadata", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const packageContents = JSON.stringify({ name: "demo", version: "1.0.0" });
const baseCandidate = createCandidate(rootDir);
fs.writeFileSync(path.join(rootDir, "package.json"), packageContents, "utf8");
const persisted = loadInstalledPluginIndex({
candidates: [{ ...baseCandidate, packageDir: rootDir }],
config: {},
env,
});
writePersistedInstalledPluginIndexSync(persisted, { stateDir });
const nestedPackageDir = path.join(rootDir, "nested");
fs.mkdirSync(nestedPackageDir, { recursive: true });
fs.writeFileSync(path.join(nestedPackageDir, "package.json"), packageContents, "utf8");
const result = loadPluginRegistrySnapshotWithMetadata({
candidates: [{ ...baseCandidate, packageDir: nestedPackageDir }],
config: {},
env,
stateDir,
});
expect(result.source).toBe("derived");
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
expect(result.snapshot.plugins[0]?.packageJson?.path).toBe("nested/package.json");
});
it("derives a complete index when a configured load-path plugin is missing", () => {
const tempRoot = makeTempDir();
const firstRoot = path.join(tempRoot, "first");
@@ -810,7 +789,24 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
const metaDir = path.join(rootDir, "..meta");
fs.mkdirSync(metaDir, { recursive: true });
const packageJsonPath = path.join(metaDir, "package.json");
fs.writeFileSync(packageJsonPath, JSON.stringify({ name: "demo", version: "1.0.0" }), "utf8");
fs.writeFileSync(
packageJsonPath,
JSON.stringify({
name: "demo",
version: "1.0.0",
openclaw: {
channel: {
id: "demo",
label: "Demo",
commands: {
nativeCommandsAutoEnabled: true,
nativeSkillsAutoEnabled: false,
},
},
},
}),
"utf8",
);
const index = loadInstalledPluginIndex({ config, env });
const [plugin] = index.plugins;
if (!plugin) {
@@ -842,6 +838,17 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expect(result.source).toBe("persisted");
expect(result.diagnostics).toStrictEqual([]);
expect(result.manifestRegistry).toBeUndefined();
const registry = loadPluginManifestRegistryForInstalledIndex({
index: result.snapshot,
config,
env,
includeDisabled: true,
});
expect(registry.plugins[0]?.channelCatalogMeta?.commands).toEqual({
nativeCommandsAutoEnabled: true,
nativeSkillsAutoEnabled: false,
});
});
it.runIf(process.platform !== "win32")(
@@ -857,6 +864,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
const config = {
plugins: {
load: { paths: [rootDir] },
entries: { demo: { enabled: false } },
},
};
writePackagePlugin(rootDir);
@@ -902,6 +910,72 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
},
);
it.runIf(process.platform !== "win32")(
"rejects dangling root, source, and manifest links for disabled records",
() => {
for (const artifact of ["root", "source", "manifest"] as const) {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
plugins: {
load: { paths: [rootDir] },
entries: { demo: { enabled: false } },
},
};
writePackagePlugin(rootDir);
writePersistedInstalledPluginIndexSync(loadInstalledPluginIndex({ config, env }), {
stateDir,
});
const artifactPath =
artifact === "root"
? rootDir
: path.join(rootDir, artifact === "source" ? "index.ts" : "openclaw.plugin.json");
fs.rmSync(artifactPath, { recursive: artifact === "root" });
fs.symlinkSync(path.join(tempRoot, "missing"), artifactPath);
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
expect([artifact, result.source]).toEqual([artifact, "derived"]);
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
}
},
);
it("rejects escaped missing package metadata for disabled records", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
const stateDir = path.join(tempRoot, "state");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
plugins: {
load: { paths: [rootDir] },
entries: { demo: { enabled: false } },
},
};
writePackagePlugin(rootDir);
const index = loadInstalledPluginIndex({ config, env });
const plugin = requirePluginRecord(index.plugins, "demo");
writePersistedInstalledPluginIndexSync(
{
...index,
plugins: [
{
...plugin,
packageJson: { path: "../gone/package.json", hash: "missing" },
},
],
},
{ stateDir },
);
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
expect(result.source).toBe("derived");
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
});
it("detects same-size same-mtime manifest replacements", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");
@@ -1048,10 +1122,10 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]);
});
it("resolves a persisted bundled root only once per registry load", () => {
it("keeps missing disabled bundled records under the trusted bundled root", () => {
const tempRoot = makeTempDir();
const packageRoot = path.join(tempRoot, "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const bundledRoot = path.join(tempRoot, "dist", "extensions");
const pluginRoot = path.join(bundledRoot, "whatsapp");
const stateDir = path.join(tempRoot, "state");
const env = {
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
@@ -1059,22 +1133,41 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
OPENCLAW_VERSION: "2026.4.26",
VITEST: "true",
};
const pluginIds = ["bundled-one", "bundled-two", "bundled-three", "bundled-four"];
for (const pluginId of pluginIds) {
writeBundledPlugin(path.join(bundledRoot, pluginId), pluginId, "index.js");
}
const index = loadInstalledPluginIndex({ config: {}, env, stateDir });
const config = { plugins: { entries: { whatsapp: { enabled: false } } } };
writeBundledPlugin(pluginRoot, "whatsapp", "index.js");
const index = loadInstalledPluginIndex({ config, env, stateDir });
writePersistedInstalledPluginIndexSync(index, { stateDir });
const realpathSpy = vi.spyOn(fs, "realpathSync");
fs.rmSync(pluginRoot, { recursive: true });
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
expect(result.source).toBe("persisted");
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId).toSorted()).toEqual(
pluginIds.toSorted(),
);
expect(realpathSpy.mock.calls.filter(([filePath]) => filePath === bundledRoot)).toHaveLength(1);
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["whatsapp"]);
expect(result.snapshot.plugins[0]?.enabled).toBe(false);
});
it("keeps missing disabled inventory beside unchanged configured plugins", () => {
const tempRoot = makeTempDir();
const liveRoot = path.join(tempRoot, "live");
const missingRoot = path.join(tempRoot, "missing");
const stateDir = path.join(tempRoot, "state");
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
const config = {
plugins: {
load: { paths: [liveRoot, missingRoot] },
entries: { missing: { enabled: false } },
},
};
writePackagePlugin(liveRoot, { pluginId: "live" });
writePackagePlugin(missingRoot, { pluginId: "missing" });
const index = loadInstalledPluginIndex({ config, env });
writePersistedInstalledPluginIndexSync(index, { stateDir });
fs.rmSync(missingRoot, { recursive: true });
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
expect(result.source).toBe("persisted");
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["live", "missing"]);
});
it("treats a persisted source bundled root as stale once its built peer appears", () => {
@@ -1112,6 +1205,49 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
]);
});
it("replaces a persisted built root when its source plugin opts out of bundled output", () => {
const tempRoot = makeTempDir();
const packageRoot = path.join(tempRoot, "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const sourcePluginDir = path.join(packageRoot, "extensions", "whatsapp");
const stateDir = path.join(tempRoot, "state");
const env = {
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_VERSION: "2026.4.26",
VITEST: "true",
};
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
writeBundledPlugin(sourcePluginDir, "whatsapp", "index.ts");
writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js");
const builtIndex = loadInstalledPluginIndex({ config: {}, env, stateDir });
expect(builtIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([
fs.realpathSync(path.join(bundledRoot, "whatsapp")),
]);
writePersistedInstalledPluginIndexSync(builtIndex, { stateDir });
fs.writeFileSync(
path.join(sourcePluginDir, "package.json"),
JSON.stringify({
name: "@openclaw/whatsapp",
version: "1.0.0",
openclaw: { extensions: ["./index.ts"], build: { bundledDist: false } },
}),
"utf8",
);
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
expect(result.source).toBe("derived");
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([
fs.realpathSync(sourcePluginDir),
]);
});
it("keeps a persisted bind-mounted source overlay when its built peer exists", () => {
const tempRoot = makeTempDir();
const packageRoot = path.join(tempRoot, "openclaw");
+332 -419
View File
@@ -1,20 +1,16 @@
// Builds stable snapshots of plugin registry contributions.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js";
import { tryReadJsonSync } from "../infra/json-files.js";
import { resolveUserPath } from "../utils.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
import { normalizePluginsConfig } from "./config-state.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js";
import { discoverConfiguredPluginLoadPaths, type PluginDiscoveryResult } from "./discovery.js";
import { resolveActivePluginInstallRoots } from "./install-root-context.js";
import { fileSignatureMatches, hashJson } from "./installed-plugin-index-hash.js";
import type { PluginDiscoveryResult } from "./discovery.js";
import { safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js";
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
import {
@@ -26,7 +22,6 @@ import {
} from "./installed-plugin-index-store.js";
import {
getInstalledPluginRecord,
extractPluginInstallRecordsFromInstalledPluginIndex,
hasMissingConfigPathActivationMetadata,
isInstalledPluginEnabled,
loadInstalledPluginIndexWithDiscovery,
@@ -36,12 +31,67 @@ import {
type LoadInstalledPluginIndexParams,
type RefreshInstalledPluginIndexParams,
} from "./installed-plugin-index.js";
import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js";
import type { PluginManifestRegistry } from "./manifest-registry.js";
import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js";
import { safeRealpathSync } from "./path-safety.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
function resolvePluginRegistryContent(
index: InstalledPluginIndex,
comparePackageJsonPath: boolean,
excludedPlugins?: ReadonlyMap<string, string>,
): unknown {
const {
generatedAtMs: _generatedAtMs,
refreshReason: _refreshReason,
warning: _warning,
...content
} = index;
const excludedRoots = [...(excludedPlugins?.values() ?? [])].map((root) => path.resolve(root));
const exclusionPathCache = new Map<string, string>();
return {
...content,
diagnostics: excludedPlugins
? content.diagnostics.filter(
(diagnostic) =>
!(
(diagnostic.pluginId && excludedPlugins.has(diagnostic.pluginId)) ||
(diagnostic.source &&
excludedRoots.some((root) =>
isContainedPluginPath(root, diagnostic.source!, exclusionPathCache),
))
),
)
: content.diagnostics,
installRecords: excludedPlugins
? Object.fromEntries(
Object.entries(content.installRecords).filter(
([pluginId]) => !excludedPlugins.has(pluginId),
),
)
: content.installRecords,
plugins: content.plugins
.filter((plugin) => !excludedPlugins?.has(plugin.pluginId))
.map((plugin) => {
const { manifestFile: _manifestFile, packageJson, ...record } = plugin;
if (!packageJson) {
return record;
}
if (!comparePackageJsonPath) {
return record;
}
const {
fileSignature: _fileSignature,
path: packageJsonPath,
...stablePackageJson
} = packageJson;
return Object.assign(record, {
packageJson: Object.assign(stablePackageJson, { path: packageJsonPath }),
});
}),
};
}
export type PluginRegistrySnapshot = InstalledPluginIndex;
export type PluginRegistryRecord = InstalledPluginIndexRecord;
type PluginRegistryInspection = InstalledPluginIndexStoreInspection;
@@ -65,36 +115,6 @@ type PluginRegistrySnapshotResult = {
manifestRegistry?: PluginManifestRegistry;
};
const REGISTRY_SNAPSHOT_MEMO_ENV_KEYS = [
"APPDATA",
"HOME",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_COMPATIBILITY_HOST_VERSION",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
"OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS",
"OPENCLAW_HOME",
"OPENCLAW_NIX_MODE",
"OPENCLAW_STATE_DIR",
"USERPROFILE",
"XDG_CONFIG_HOME",
] as const;
type PluginRegistrySnapshotMemo = {
key: string;
result: PluginRegistrySnapshotResult;
};
let pluginRegistrySnapshotMemo: PluginRegistrySnapshotMemo | undefined;
function clearLoadPluginRegistrySnapshotMemo(): void {
pluginRegistrySnapshotMemo = undefined;
// A retired registry must not leave its published metadata graph behind.
clearCurrentPluginMetadataSnapshot();
}
registerPluginMetadataProcessMemoLifecycleClear(clearLoadPluginRegistrySnapshotMemo);
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams &
InstalledPluginIndexStoreOptions & {
index?: PluginRegistrySnapshot;
@@ -105,68 +125,6 @@ type GetPluginRecordParams = LoadPluginRegistryParams & {
pluginId: string;
};
function pickRegistrySnapshotMemoEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
REGISTRY_SNAPSHOT_MEMO_ENV_KEYS.flatMap((key) => {
const value = env[key];
return value === undefined ? [] : [[key, value]];
}),
);
}
function canMemoizePluginRegistrySnapshot(params: LoadPluginRegistryParams): boolean {
return (
params.index === undefined &&
params.candidates === undefined &&
params.diagnostics === undefined &&
params.discovery === undefined &&
params.installRecords === undefined &&
params.now === undefined &&
params.filePath === undefined &&
params.pluginIndexFilePath === undefined
);
}
function resolvePluginRegistrySnapshotMemoKey(
params: LoadPluginRegistryParams,
env: NodeJS.ProcessEnv,
): string | undefined {
if (!canMemoizePluginRegistrySnapshot(params)) {
return undefined;
}
return hashJson({
config: params.config ?? null,
cwd: process.cwd(),
env: pickRegistrySnapshotMemoEnv(env),
installRoots: resolveActivePluginInstallRoots(env),
hostContractVersion: resolveCompatibilityHostVersion(env),
preferPersisted: params.preferPersisted ?? null,
// Install, reload, and persisted-index writes clear this memo explicitly.
// Polling roots or SQLite here would put discovery back on every hot lookup.
stateDir: params.stateDir ? resolveUserPath(params.stateDir, env) : null,
workspaceDir: params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : null,
});
}
function findPluginRegistrySnapshotMemo(
key: string | undefined,
): PluginRegistrySnapshotResult | undefined {
return key && pluginRegistrySnapshotMemo?.key === key
? pluginRegistrySnapshotMemo.result
: undefined;
}
function rememberPluginRegistrySnapshotMemo(
key: string | undefined,
result: PluginRegistrySnapshotResult,
): PluginRegistrySnapshotResult {
if (!key) {
return result;
}
pluginRegistrySnapshotMemo = { key, result };
return result;
}
function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean {
return (
params.preferPersisted !== false &&
@@ -176,6 +134,7 @@ function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams)
params.installRecords === undefined &&
params.candidates === undefined &&
params.diagnostics === undefined &&
params.discovery === undefined &&
params.now === undefined
);
}
@@ -186,266 +145,194 @@ function loadCurrentPluginRegistrySnapshotResult(
if (!canReuseCurrentPluginMetadataSnapshot(params)) {
return undefined;
}
const env = params.env ?? process.env;
const current = getCurrentPluginMetadataSnapshot({
config: params.config,
env,
env: params.env ?? process.env,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
if (!current || current.registryDiagnostics.length > 0) {
if (!current) {
return undefined;
}
return {
snapshot: current.index,
source: "provided",
source:
current.registrySource ?? (current.registryDiagnostics.length > 0 ? "derived" : "provided"),
diagnostics: current.registryDiagnostics,
...(current.discovery ? { discovery: current.discovery } : {}),
manifestRegistry: current.manifestRegistry,
};
}
function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean {
function fileContentMatches(
filePath: string,
hash: string,
signature?: InstalledPluginIndexRecord["manifestFile"],
trustSignature = true,
): boolean {
const current = safeFileSignature(filePath);
if (!current) {
return false;
}
if (
trustSignature &&
signature?.ctimeMs !== undefined &&
current.size === signature.size &&
current.mtimeMs === signature.mtimeMs &&
current.ctimeMs === signature.ctimeMs
) {
return true;
}
return safeHashFile({ filePath, diagnostics: [], required: false }) === hash;
}
function isContainedPluginPath(
rootPath: string,
targetPath: string,
cache: Map<string, string>,
): boolean {
// Project unresolved suffixes from the nearest real ancestor so missing disabled
// artifacts stay inspectable without accepting symlink or path-alias escapes.
const resolveProjectedPath = (inputPath: string): string | null => {
const target = path.resolve(inputPath);
for (let cursor = target; ; cursor = path.dirname(cursor)) {
try {
fs.lstatSync(cursor);
const realCursor = safeRealpathSync(cursor, cache);
return realCursor ? path.resolve(realCursor, path.relative(cursor, target)) : null;
} catch {
if (cursor === path.dirname(cursor)) {
return null;
}
}
}
};
const root = resolveProjectedPath(rootPath);
const target = resolveProjectedPath(targetPath);
return Boolean(root && target && isPathInside(root, target));
}
function hasStalePersistedPluginFiles(index: InstalledPluginIndex): boolean {
const realpathCache = new Map<string, string>();
return index.plugins.some((plugin) => {
if (!plugin.enabled) {
if (!isContainedPluginPath(plugin.rootDir, plugin.rootDir, realpathCache)) {
return true;
}
if (!fs.existsSync(plugin.rootDir) && plugin.enabled) {
return true;
}
for (const artifactPath of [plugin.source, plugin.setupSource, plugin.manifestPath]) {
if (artifactPath && !isContainedPluginPath(plugin.rootDir, artifactPath, realpathCache)) {
return true;
}
}
if (
plugin.enabled &&
((plugin.source ? !fs.existsSync(plugin.source) : false) ||
(plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false))
) {
return true;
}
if (!hasOptionalMissingPluginManifestFile(plugin)) {
if (!fs.existsSync(plugin.manifestPath)) {
if (plugin.enabled) {
return true;
}
} else if (
!fileContentMatches(plugin.manifestPath, plugin.manifestHash, plugin.manifestFile)
) {
return true;
}
}
if (!plugin.packageJson) {
return false;
}
return (
!fs.existsSync(plugin.rootDir) ||
(!hasOptionalMissingPluginManifestFile(plugin) && !fs.existsSync(plugin.manifestPath)) ||
(plugin.source ? !fs.existsSync(plugin.source) : false) ||
(plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false)
const packageJsonPath = path.resolve(plugin.rootDir, plugin.packageJson.path);
if (!isContainedPluginPath(plugin.rootDir, packageJsonPath, realpathCache)) {
return true;
}
if (!fs.existsSync(packageJsonPath)) {
return plugin.enabled;
}
if (!isRealPathInside(plugin.rootDir, packageJsonPath, realpathCache)) {
return true;
}
return !fileContentMatches(
packageJsonPath,
plugin.packageJson.hash,
plugin.packageJson.fileSignature,
plugin.origin === "bundled",
);
});
}
function hasMismatchedPersistedConfigPathPlugins(
index: InstalledPluginIndex,
params: LoadPluginRegistryParams,
env: NodeJS.ProcessEnv,
realpathCache: Map<string, string>,
): boolean {
const loadPaths = normalizePluginsConfig(params.config?.plugins).loadPaths;
const discovery = discoverConfiguredPluginLoadPaths({
loadPaths,
workspaceDir: params.workspaceDir,
env,
});
const configuredRoots = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(index),
}).plugins.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache));
const persistedRoots = index.plugins
.filter((plugin) => plugin.origin === "config")
.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache));
if (configuredRoots.length !== persistedRoots.length) {
return true;
}
return configuredRoots.some((rootDir, position) => rootDir !== persistedRoots[position]);
}
function resolveComparablePath(filePath: string, realpathCache: Map<string, string>): string {
return safeRealpathSync(filePath, realpathCache) ?? path.resolve(filePath);
}
function isRelativePathInsideOrEqual(relativePath: string): boolean {
return (
relativePath === "" ||
(relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath))
);
}
function isPathInsideOrEqual(
childPath: string,
function isRealPathInside(
parentPath: string,
realpathCache: Map<string, string>,
childPath: string,
cache: Map<string, string>,
): boolean {
const relative = path.relative(
resolveComparablePath(parentPath, realpathCache),
resolveComparablePath(childPath, realpathCache),
);
return isRelativePathInsideOrEqual(relative);
const parent = safeRealpathSync(parentPath, cache);
const child = safeRealpathSync(childPath, cache);
return Boolean(parent && child && isPathInside(parent, child));
}
function hasMismatchedPersistedBundledPluginRoot(
function hasMismatchedPersistedBundledRoot(
index: InstalledPluginIndex,
env: NodeJS.ProcessEnv,
realpathCache: Map<string, string>,
): boolean {
const bundledPluginsDir = resolveBundledPluginsDir(env);
if (!bundledPluginsDir) {
const bundledRoot = resolveBundledPluginsDir(env);
if (!bundledRoot) {
return false;
}
let sourceOverlayDirs: string[] | undefined;
const realpathCache = new Map<string, string>();
const overlays = listBundledSourceOverlayDirs({ bundledRoot, env });
const legacyRoot = buildLegacyBundledRootPath(bundledRoot);
const sourceCheckout =
legacyRoot &&
fs.existsSync(path.join(path.dirname(legacyRoot), ".git")) &&
fs.existsSync(path.join(path.dirname(legacyRoot), "pnpm-workspace.yaml")) &&
fs.existsSync(path.join(path.dirname(legacyRoot), "src"));
return index.plugins.some((plugin) => {
if (plugin.origin !== "bundled") {
return false;
}
sourceOverlayDirs ??= listBundledSourceOverlayDirs({
bundledRoot: bundledPluginsDir,
env,
});
return !isAllowedPersistedBundledPluginRoot(
plugin,
bundledPluginsDir,
sourceOverlayDirs,
realpathCache,
);
});
}
function isAllowedPersistedBundledPluginRoot(
plugin: InstalledPluginIndexRecord,
bundledPluginsDir: string,
sourceOverlayDirs: readonly string[],
realpathCache: Map<string, string>,
): boolean {
const pluginRootDir = plugin.rootDir;
const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir);
if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir, realpathCache)) {
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
return true;
}
const relativePluginRoot = path.relative(
resolveComparablePath(bundledPluginsDir, realpathCache),
resolveComparablePath(pluginRootDir, realpathCache),
);
return !sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot));
}
if (
sourceOverlayDirs.some((overlayDir) =>
isPathInsideOrEqual(pluginRootDir, overlayDir, realpathCache),
)
) {
return true;
}
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
return false;
}
const relativePluginRoot = path.relative(
resolveComparablePath(legacyRoot, realpathCache),
resolveComparablePath(pluginRootDir, realpathCache),
);
if (!isRelativePathInsideOrEqual(relativePluginRoot)) {
return false;
}
if (plugin.packageBuild?.bundledDist === false) {
return true;
}
if (sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot))) {
// Older index records lack packageBuild. Re-derive once so runtime loading
// and OpenClaw fingerprint the same source-only artifact.
return false;
}
// Discovery prefers a built plugin whenever the same child exists in the
// packaged root. Keep source-only bundled plugins, but invalidate stale
// source records once their built peer appears.
return !fs.existsSync(path.join(bundledPluginsDir, relativePluginRoot));
}
function sourcePluginOptsOutOfBundledDist(pluginRootDir: string): boolean {
const packageJson = tryReadJsonSync<PackageManifest>(path.join(pluginRootDir, "package.json"));
return getPackageManifestMetadata(packageJson ?? undefined)?.build?.bundledDist === false;
}
function isSourceCheckoutBundledPluginRoot(extensionsDir: string): boolean {
const packageRoot = path.dirname(extensionsDir);
return (
fs.existsSync(extensionsDir) &&
fs.existsSync(path.join(packageRoot, ".git")) &&
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) &&
fs.existsSync(path.join(packageRoot, "src"))
);
}
function hashExistingFile(filePath: string): string | null {
try {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
} catch {
return null;
}
}
function resolveRecordPackageJsonPath(
plugin: InstalledPluginIndexRecord,
realpathCache: Map<string, string>,
): string | null {
const packageJsonPath = plugin.packageJson?.path;
if (!packageJsonPath) {
return null;
}
const rootDir = plugin.rootDir || path.dirname(plugin.manifestPath);
const resolved = path.resolve(rootDir, packageJsonPath);
const relative = path.relative(rootDir, resolved);
if (!isRelativePathInsideOrEqual(relative)) {
return null;
}
const realRelative = path.relative(
resolveComparablePath(rootDir, realpathCache),
resolveComparablePath(resolved, realpathCache),
);
return isRelativePathInsideOrEqual(realRelative) ? resolved : null;
}
function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolean {
return index.diagnostics.some((diag) => {
const source = diag.source;
return (
typeof diag.pluginId === "string" &&
diag.pluginId.trim().length > 0 &&
typeof source === "string" &&
path.isAbsolute(source) &&
!fs.existsSync(source)
);
});
}
function hasStalePersistedPluginMetadata(
index: InstalledPluginIndex,
realpathCache: Map<string, string>,
): boolean {
return index.plugins.some((plugin) => {
if (!hasOptionalMissingPluginManifestFile(plugin)) {
const manifestSignatureMatches = fileSignatureMatches(
plugin.manifestPath,
plugin.manifestFile,
if (!plugin.enabled && !fs.existsSync(plugin.rootDir)) {
const allowedRoots = [bundledRoot, ...overlays, ...(legacyRoot ? [legacyRoot] : [])];
return !allowedRoots.some((root) =>
isContainedPluginPath(root, plugin.rootDir, realpathCache),
);
if (manifestSignatureMatches !== true) {
const manifestHash = hashExistingFile(plugin.manifestPath);
if (manifestHash && manifestHash !== plugin.manifestHash) {
return true;
}
}
if (isRealPathInside(bundledRoot, plugin.rootDir, realpathCache)) {
if (!sourceCheckout) {
return false;
}
const resolvedBundledRoot = safeRealpathSync(bundledRoot, realpathCache) ?? bundledRoot;
const resolvedPluginRoot = safeRealpathSync(plugin.rootDir, realpathCache) ?? plugin.rootDir;
const sourcePackage = tryReadJsonSync<PackageManifest>(
path.join(
legacyRoot,
path.relative(resolvedBundledRoot, resolvedPluginRoot),
"package.json",
),
);
return getPackageManifestMetadata(sourcePackage ?? undefined)?.build?.bundledDist === false;
}
const packageJsonPath = resolveRecordPackageJsonPath(plugin, realpathCache);
if (!plugin.packageJson?.hash) {
return false;
}
if (!packageJsonPath) {
return true;
}
const packageJsonSignatureMatches = fileSignatureMatches(
packageJsonPath,
plugin.packageJson.fileSignature,
return (
!overlays.some((root) => isRealPathInside(root, plugin.rootDir, realpathCache)) &&
!(
plugin.packageBuild?.bundledDist === false &&
legacyRoot &&
isRealPathInside(legacyRoot, plugin.rootDir, realpathCache)
)
);
if (packageJsonSignatureMatches === true && plugin.origin === "bundled") {
return false;
}
if (packageJsonSignatureMatches === false) {
return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash;
}
// Fast same-size rewrites can preserve observable stat fields on some filesystems.
const packageJsonHash = hashExistingFile(packageJsonPath);
return packageJsonHash !== plugin.packageJson.hash;
});
}
function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv) {
return loadInstalledPluginIndexInstallRecordsSync({
function hasRecoveredInstallRecordsMissingFromPersistedIndex(
index: InstalledPluginIndex,
params: LoadPluginRegistryParams,
env: NodeJS.ProcessEnv,
): boolean {
const installRecords = loadInstalledPluginIndexInstallRecordsSync({
env,
...(params.stateDir ? { stateDir: params.stateDir } : {}),
...(params.filePath
@@ -454,28 +341,32 @@ function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJ
? { filePath: params.pluginIndexFilePath }
: {}),
});
const pluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
return Object.keys(installRecords).some(
(pluginId) => !index.installRecords?.[pluginId] || !pluginIds.has(pluginId),
);
}
function hasRecoveredInstallRecordsMissingFromPersistedIndex(
function requiresDerivedRegistryValidation(
index: InstalledPluginIndex,
installRecords: ReturnType<typeof loadInstalledPluginIndexInstallRecordsSync>,
params: LoadPluginRegistryParams,
env: NodeJS.ProcessEnv,
hasStalePluginFiles: () => boolean,
): boolean {
const persistedRecords = extractPluginInstallRecordsFromInstalledPluginIndex(index);
const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
return Object.entries(installRecords).some(([pluginId, record]) => {
if (persistedRecords[pluginId] && persistedPluginIds.has(pluginId)) {
return false;
}
const installPaths = [record.installPath, record.sourcePath].filter(
(candidate): candidate is string =>
typeof candidate === "string" && candidate.trim().length > 0,
);
if (installPaths.length === 0) {
return true;
}
return installPaths.some((installPath) => fs.existsSync(resolveUserPath(installPath, env)));
});
return (
params.candidates !== undefined ||
params.discovery !== undefined ||
params.diagnostics !== undefined ||
params.installRecords !== undefined ||
normalizePluginsConfig(params.config?.plugins).loadPaths.length > 0 ||
hasMissingConfigPathActivationMetadata(index) ||
index.diagnostics.some(({ pluginId, source }) =>
Boolean(pluginId && source && path.isAbsolute(source) && !fs.existsSync(source)),
) ||
hasMismatchedPersistedBundledRoot(index, env) ||
hasStalePluginFiles() ||
hasRecoveredInstallRecordsMissingFromPersistedIndex(index, params, env)
);
}
export function loadPluginRegistrySnapshotWithMetadata(
@@ -494,96 +385,117 @@ export function loadPluginRegistrySnapshotWithMetadata(
}
const env = params.env ?? process.env;
const memoKey = resolvePluginRegistrySnapshotMemoKey(params, env);
const memo = findPluginRegistrySnapshotMemo(memoKey);
if (memo) {
return memo;
}
// Bound canonical paths to this registry build; lifecycle changes must
// never reuse security-sensitive symlink or plugin-root resolutions.
const realpathCache = new Map<string, string>();
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
const persistedReadsEnabled = params.preferPersisted !== false;
const pushStaleSourceDiagnostic = (message: string): void => {
diagnostics.push({ level: "warn", code: "persisted-registry-stale-source", message });
};
if (persistedReadsEnabled) {
const persistedIndex = readPersistedInstalledPluginIndexSync(params);
if (persistedIndex) {
if (
params.config &&
persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config)
) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-policy",
message:
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasMissingPersistedPluginSource(persistedIndex)) {
pushStaleSourceDiagnostic(
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env, realpathCache)) {
pushStaleSourceDiagnostic(
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (
hasMismatchedPersistedConfigPathPlugins(persistedIndex, params, env, realpathCache)
) {
pushStaleSourceDiagnostic(
"Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (hasStalePersistedPluginDiagnostics(persistedIndex)) {
pushStaleSourceDiagnostic(
"Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (hasMissingConfigPathActivationMetadata(persistedIndex)) {
pushStaleSourceDiagnostic(
"Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (hasStalePersistedPluginMetadata(persistedIndex, realpathCache)) {
pushStaleSourceDiagnostic(
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else if (
hasRecoveredInstallRecordsMissingFromPersistedIndex(
persistedIndex,
loadSnapshotInstallRecords(params, env),
env,
)
) {
pushStaleSourceDiagnostic(
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
);
} else {
const persistedResult: PluginRegistrySnapshotResult = {
snapshot: persistedIndex,
source: "persisted",
diagnostics,
};
return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult);
}
} else {
diagnostics.push({
level: "info",
code: "persisted-registry-missing",
message: "Persisted plugin registry is missing or invalid; using derived plugin index.",
});
}
if (!persistedReadsEnabled) {
const derived = loadInstalledPluginIndexWithDiscovery({
...params,
installRecords: params.installRecords ?? {},
});
return {
snapshot: derived.index,
source: "derived",
diagnostics: [],
discovery: derived.discovery,
manifestRegistry: derived.manifestRegistry,
};
}
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
const persistedIndex = readPersistedInstalledPluginIndexSync(params);
let stalePluginFiles: boolean | undefined;
const hasStalePluginFiles = () =>
(stalePluginFiles ??= persistedIndex ? hasStalePersistedPluginFiles(persistedIndex) : false);
if (!persistedIndex) {
diagnostics.push({
level: "info",
code: "persisted-registry-missing",
message: "Persisted plugin registry is missing or invalid; using derived plugin index.",
});
} else if (
params.config &&
persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config)
) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-policy",
message:
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (!requiresDerivedRegistryValidation(persistedIndex, params, env, hasStalePluginFiles)) {
return {
snapshot: persistedIndex,
source: "persisted",
diagnostics,
};
}
const derived = loadInstalledPluginIndexWithDiscovery({
...params,
installRecords: persistedReadsEnabled ? params.installRecords : (params.installRecords ?? {}),
...(params.filePath && !params.pluginIndexFilePath
? { pluginIndexFilePath: params.filePath }
: {}),
});
return rememberPluginRegistrySnapshotMemo(memoKey, {
const comparePackageJsonPath =
params.candidates !== undefined || params.discovery !== undefined || hasStalePluginFiles();
const excludedMissingDisabledPlugins = new Map<string, string>();
if (
persistedIndex &&
params.candidates === undefined &&
params.discovery === undefined &&
params.installRecords === undefined &&
!hasStalePluginFiles() &&
!hasMismatchedPersistedBundledRoot(persistedIndex, env)
) {
const derivedPluginIds = new Set(derived.index.plugins.map((plugin) => plugin.pluginId));
for (const plugin of persistedIndex.plugins) {
if (!plugin.enabled && !derivedPluginIds.has(plugin.pluginId)) {
excludedMissingDisabledPlugins.set(plugin.pluginId, plugin.rootDir);
}
}
}
const contentMatches =
persistedIndex &&
diagnostics.length === 0 &&
isDeepStrictEqual(
resolvePluginRegistryContent(
persistedIndex,
comparePackageJsonPath,
excludedMissingDisabledPlugins,
),
resolvePluginRegistryContent(
derived.index,
comparePackageJsonPath,
excludedMissingDisabledPlugins,
),
);
if (persistedIndex && contentMatches) {
const packageMetadataMatches = isDeepStrictEqual(
resolvePluginRegistryContent(persistedIndex, true),
resolvePluginRegistryContent(derived.index, true),
);
return {
snapshot: persistedIndex,
source: "persisted",
diagnostics,
discovery: derived.discovery,
...(packageMetadataMatches ? { manifestRegistry: derived.manifestRegistry } : {}),
};
} else if (persistedIndex && diagnostics.length === 0) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry no longer matches current plugin discovery or metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
}
return {
snapshot: derived.index,
source: "derived",
diagnostics,
discovery: derived.discovery,
manifestRegistry: derived.manifestRegistry,
});
};
}
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {
@@ -595,6 +507,7 @@ export function loadPluginRegistrySnapshot(
): PluginRegistrySnapshot {
return resolveSnapshot(params);
}
export function getPluginRecord(params: GetPluginRecordParams): PluginRegistryRecord | undefined {
return getInstalledPluginRecord(resolveSnapshot(params), params.pluginId);
}
+88 -70
View File
@@ -4,10 +4,7 @@ import fs from "node:fs";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
closeOpenClawStateDatabaseForTest,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import type { PluginCandidate } from "./discovery.js";
import {
readPersistedInstalledPluginIndex,
@@ -169,15 +166,6 @@ function createIndex(
};
}
function createPersistableIndex(pluginId: string): InstalledPluginIndex {
const index = createIndex(pluginId);
const plugins = index.plugins.map((plugin) => Object.assign({}, plugin, { enabled: false }));
return {
...index,
plugins,
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label}`);
@@ -330,6 +318,29 @@ describe("plugin registry facade", () => {
).toEqual(["demo"]);
});
it("keeps missing disabled records inspectable from the persisted registry", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const config = { plugins: { entries: { demo: { enabled: false } } } };
const env = hermeticEnv();
const persisted = loadPluginRegistrySnapshot({
candidates: [createCandidate(rootDir)],
config,
env,
preferPersisted: false,
});
await writePersistedInstalledPluginIndex(persisted, { stateDir });
fs.rmSync(rootDir, { recursive: true });
const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, config, env });
expect(result.source).toBe("persisted");
expectPluginRecordFields(getPluginRecord({ index: result.snapshot, pluginId: "demo" }), {
pluginId: "demo",
enabled: false,
});
});
it("resolves contribution owners from a plugin lookup table without rereading manifests", () => {
const rootDir = makeTempDir();
const candidate = createCandidate(rootDir);
@@ -471,7 +482,7 @@ describe("plugin registry facade", () => {
expect(normalizedConfig.allow).toEqual(["demo"]);
});
it("reads the persisted registry before deriving from discovered candidates", async () => {
it("treats explicit discovered candidates as authoritative", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const persistedRootDir = makeTempDir();
@@ -509,13 +520,70 @@ describe("plugin registry facade", () => {
env: hermeticEnv(),
});
expect(result.source).toBe("persisted");
expect(result.diagnostics).toStrictEqual([]);
expect(result.source).toBe("derived");
expectDiagnosticCodes(result.diagnostics, ["persisted-registry-stale-source"]);
expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([
"persisted",
"demo",
]);
});
it("keeps content-equivalent timestamp changes on the persisted path", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const env = hermeticEnv();
const persisted = loadPluginRegistrySnapshot({
candidates: [createCandidate(rootDir)],
env,
preferPersisted: false,
});
await writePersistedInstalledPluginIndex(
{
...persisted,
plugins: [
{
...expectDefined(persisted.plugins[0], "persisted plugin test invariant"),
syntheticAuthRefs: ["demo"],
},
...persisted.plugins.slice(1),
],
},
{ stateDir },
);
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
const future = new Date(Date.now() + 1_000);
fs.utimesSync(manifestPath, future, future);
const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
expect(result.source).toBe("persisted");
expect(result.snapshot.plugins[0]?.syntheticAuthRefs).toEqual(["demo"]);
});
it("reads install records from a custom SQLite registry path", async () => {
const tempDir = makeTempDir();
const rootDir = makeTempDir();
const filePath = path.join(tempDir, "custom-registry.sqlite");
const env = hermeticEnv();
const persisted = loadPluginRegistrySnapshot({
candidates: [createCandidate(rootDir)],
env,
preferPersisted: false,
});
persisted.installRecords = {
demo: { source: "npm", spec: "demo@1.0.0", installPath: rootDir },
};
await writePersistedInstalledPluginIndex(persisted, { filePath });
const result = loadPluginRegistrySnapshotWithMetadata({ filePath, env });
expect(result.source).toBe("persisted");
expectInstallRecord(result.snapshot.installRecords, "demo", {
source: "npm",
spec: "demo@1.0.0",
installPath: rootDir,
});
});
it("falls back to the derived registry when persisted source paths are missing", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
@@ -819,7 +887,7 @@ describe("plugin registry facade", () => {
expectSnapshotPluginIds(result.snapshot, ["demo"]);
});
it("reuses config-scoped derived registries within the process", () => {
it("derives config-scoped registries for cold callers", () => {
const stateDir = makeTempDir();
const workspaceDir = makeTempDir();
const bundledRoot = makeTempDir();
@@ -853,7 +921,7 @@ describe("plugin registry facade", () => {
expect(first.source).toBe("derived");
expect(second.source).toBe("derived");
expect(manifestReadsAfterFirst).toBeGreaterThan(0);
expect(manifestReadsAfterSecond).toBe(manifestReadsAfterFirst);
expect(manifestReadsAfterSecond).toBeGreaterThan(manifestReadsAfterFirst);
});
it("reloads profile extensions after the metadata lifecycle is cleared", () => {
@@ -881,7 +949,7 @@ describe("plugin registry facade", () => {
expectSnapshotPluginIds(second.snapshot, ["first", "second"]);
});
it("keys the process registry memo by resolved host contract version", () => {
it("derives the resolved host contract version", () => {
const stateDir = makeTempDir();
const bundledRoot = makeTempDir();
const rootDir = path.join(bundledRoot, "demo");
@@ -907,56 +975,6 @@ describe("plugin registry facade", () => {
expect(second.snapshot.hostContractVersion).toBe("2026.4.26");
});
it("clears the process registry memo after persisted registry writes", async () => {
const stateDir = makeTempDir();
const env = hermeticEnv();
await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir });
const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
await writePersistedInstalledPluginIndex(createPersistableIndex("second"), { stateDir });
const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
expect(first.source).toBe("persisted");
expect(second.source).toBe("persisted");
expectSnapshotPluginIds(first.snapshot, ["first"]);
expectSnapshotPluginIds(second.snapshot, ["second"]);
});
it("reloads externally changed persisted state after the metadata lifecycle is cleared", async () => {
const stateDir = makeTempDir();
const env = hermeticEnv();
await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir });
const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
const external = createPersistableIndex("second-external");
runOpenClawStateWriteTransaction(
({ db }) => {
db.prepare(
`
UPDATE installed_plugin_index
SET plugins_json = ?,
install_records_json = ?,
diagnostics_json = ?,
updated_at_ms = ?
WHERE index_key = 'installed-plugin-index'
`,
).run(
JSON.stringify(external.plugins),
JSON.stringify(external.installRecords),
JSON.stringify(external.diagnostics),
Date.now(),
);
},
{ env: { ...env, OPENCLAW_STATE_DIR: stateDir } },
);
clearPluginMetadataLifecycleCaches();
const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
expect(first.source).toBe("persisted");
expect(second.source).toBe("persisted");
expectSnapshotPluginIds(first.snapshot, ["first"]);
expectSnapshotPluginIds(second.snapshot, ["second-external"]);
});
it("derives a fresh registry without persisted install records when caller disables persisted reads", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
+220 -388
View File
@@ -35,6 +35,22 @@ function writeSecureFile(file: string, contents: string): void {
fs.chmodSync(file, 0o600);
}
function writePluginManifest(rootDir: string, manifest: Record<string, unknown>): void {
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
...manifest,
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
}
function createCandidate(
rootDir: string,
idHint: string,
@@ -48,6 +64,16 @@ function createCandidate(
};
}
function loadTestRegistry(
rootDir: string,
idHint: string,
origin: PluginOrigin = "global",
): PluginManifestRegistry {
return loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, idHint, origin)],
});
}
function pluginIntegrationProviderConfig(pluginId: string, integrationId: string) {
return {
source: "exec" as const,
@@ -67,45 +93,33 @@ afterEach(() => {
describe("secret provider integration presets", () => {
it("materializes plugin manifest exec providers without provider-specific core code", () => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
makeSecureDir(path.join(rootDir, "bin"));
writeSecureFile(path.join(rootDir, "bin", "resolve.mjs"), "process.stdin.resume();\n");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "acme-secrets",
name: "Acme Secrets",
secretProviderIntegrations: {
acme: {
providerAlias: "acme",
displayName: "Acme Vault",
description: "Acme exec resolver",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs", "--profile", "work"],
timeoutMs: 3000,
noOutputTimeoutMs: 3000,
maxOutputBytes: 4096,
passEnv: ["HOME"],
env: {
ACME_PROFILE: "work",
},
jsonOnly: false,
writePluginManifest(rootDir, {
id: "acme-secrets",
name: "Acme Secrets",
secretProviderIntegrations: {
acme: {
providerAlias: "acme",
displayName: "Acme Vault",
description: "Acme exec resolver",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs", "--profile", "work"],
timeoutMs: 3000,
noOutputTimeoutMs: 3000,
maxOutputBytes: 4096,
passEnv: ["HOME"],
env: {
ACME_PROFILE: "work",
},
jsonOnly: false,
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "acme-secrets")],
},
});
const registry = loadTestRegistry(rootDir, "acme-secrets");
expect(registry.diagnostics).toEqual([]);
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
{
@@ -144,36 +158,24 @@ describe("secret provider integration presets", () => {
it("normalizes manifest exec provider options to SecretRef provider schema limits", () => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "bounded-secrets",
secretProviderIntegrations: {
bounded: {
source: "exec",
command: "${node}",
args: ["./resolve.mjs", "ok", "x".repeat(1025)],
timeoutMs: 120001,
noOutputTimeoutMs: 1.5,
maxOutputBytes: 20 * 1024 * 1024 + 1,
passEnv: ["GOOD_ENV", "bad-env"],
},
writePluginManifest(rootDir, {
id: "bounded-secrets",
secretProviderIntegrations: {
bounded: {
source: "exec",
command: "${node}",
args: ["./resolve.mjs", "ok", "x".repeat(1025)],
timeoutMs: 120001,
noOutputTimeoutMs: 1.5,
maxOutputBytes: 20 * 1024 * 1024 + 1,
passEnv: ["GOOD_ENV", "bad-env"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "bounded-secrets")],
},
});
const registry = loadTestRegistry(rootDir, "bounded-secrets");
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
{
id: "bounded",
@@ -203,31 +205,19 @@ describe("secret provider integration presets", () => {
it("skips presets whose provider alias cannot be used as a SecretRef provider", () => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "bad-secrets",
secretProviderIntegrations: {
bad: {
providerAlias: "../bad",
source: "exec",
command: "${node}",
},
writePluginManifest(rootDir, {
id: "bad-secrets",
secretProviderIntegrations: {
bad: {
providerAlias: "../bad",
source: "exec",
command: "${node}",
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "bad-secrets")],
},
});
const registry = loadTestRegistry(rootDir, "bad-secrets");
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
});
@@ -236,54 +226,34 @@ describe("secret provider integration presets", () => {
const longPluginRootDir = makeTempDir();
const longPluginId = `plugin-${"x".repeat(129)}`;
const longIntegrationId = `integration-${"x".repeat(129)}`;
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.writeFileSync(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n", "utf8");
fs.writeFileSync(path.join(longPluginRootDir, "index.ts"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(longPluginRootDir, "resolve.mjs"),
"process.stdin.resume();\n",
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "long-integration-secrets",
secretProviderIntegrations: {
[longIntegrationId]: {
providerAlias: "short-alias",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "long-integration-secrets",
secretProviderIntegrations: {
[longIntegrationId]: {
providerAlias: "short-alias",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
});
writePluginManifest(longPluginRootDir, {
id: longPluginId,
secretProviderIntegrations: {
vault: {
providerAlias: "short-plugin-alias",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
}),
"utf8",
);
fs.writeFileSync(
path.join(longPluginRootDir, "openclaw.plugin.json"),
JSON.stringify({
id: longPluginId,
secretProviderIntegrations: {
vault: {
providerAlias: "short-plugin-alias",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
},
});
const registry = loadPluginManifestRegistry({
candidates: [
@@ -299,61 +269,39 @@ describe("secret provider integration presets", () => {
"skips non-node manifest preset commands for %s plugin roots",
(origin) => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.mkdirSync(path.join(rootDir, "bin"));
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: `${origin}-secrets`,
...(origin === "bundled" ? { enabledByDefault: true } : {}),
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "./bin/vault-resolver",
},
writePluginManifest(rootDir, {
id: `${origin}-secrets`,
...(origin === "bundled" ? { enabledByDefault: true } : {}),
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "./bin/vault-resolver",
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)],
},
});
const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin);
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
},
);
it("skips presets from disabled installed plugins", () => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "disabled-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "disabled-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
},
});
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "disabled-secrets", "global")],
@@ -386,28 +334,18 @@ describe("secret provider integration presets", () => {
it("applies plugin id aliases when filtering disabled presets", () => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "openai",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "openai",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
},
});
const config = {
plugins: {
entries: {
@@ -429,32 +367,20 @@ describe("secret provider integration presets", () => {
it("exposes bundled presets enabled by platform default", () => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "platform-secrets",
enabledByDefaultOnPlatforms: [process.platform],
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "platform-secrets",
enabledByDefaultOnPlatforms: [process.platform],
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "platform-secrets", "bundled")],
},
});
const registry = loadTestRegistry(rootDir, "platform-secrets", "bundled");
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
{
@@ -473,33 +399,21 @@ describe("secret provider integration presets", () => {
const rootDir = makeTempDir();
const linkParent = makeTempDir();
const linkRoot = path.join(linkParent, "plugin-link");
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "linked-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "linked-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
},
});
fs.symlinkSync(rootDir, linkRoot);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(linkRoot, "linked-secrets", "global")],
});
const registry = loadTestRegistry(linkRoot, "linked-secrets", "global");
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
{
@@ -517,32 +431,20 @@ describe("secret provider integration presets", () => {
"skips secret provider presets from %s plugin roots",
(origin) => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: `${origin}-secrets`,
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
writePluginManifest(rootDir, {
id: `${origin}-secrets`,
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)],
},
});
const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin);
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
},
);
@@ -550,7 +452,6 @@ describe("secret provider integration presets", () => {
it("resolves a node-based plugin preset with plugin trusted dirs", async () => {
const rootDir = makeTempDir();
const resolverPath = path.join(rootDir, "bin", "resolve.mjs");
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
makeSecureDir(path.dirname(resolverPath));
writeSecureFile(
resolverPath,
@@ -565,32 +466,21 @@ describe("secret provider integration presets", () => {
"});",
].join("\n"),
);
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "vault-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs"],
allowInsecurePath: true,
},
writePluginManifest(rootDir, {
id: "vault-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs"],
allowInsecurePath: true,
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
},
});
await withSecureTestNodeExecPath(async () => {
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "vault-secrets", "global")],
});
const registry = loadTestRegistry(rootDir, "vault-secrets", "global");
const [preset] = listSecretProviderIntegrationPresets({ manifestRegistry: registry });
if (!preset) {
throw new Error("Expected vault preset");
@@ -624,28 +514,18 @@ describe("secret provider integration presets", () => {
it("fails closed when a plugin-managed provider is disabled", async () => {
const rootDir = makeTempDir();
const resolverPath = path.join(rootDir, "resolve.mjs");
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.writeFileSync(resolverPath, "process.stdin.resume();\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "revoked-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "revoked-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
},
});
const config = {
plugins: {
entries: {
@@ -715,31 +595,19 @@ describe("secret provider integration presets", () => {
it("skips node presets without a plugin-root relative entrypoint arg", () => {
const rootDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "bad-trust-secrets",
secretProviderIntegrations: {
bad: {
source: "exec",
command: "${node}",
args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "bad-trust-secrets",
secretProviderIntegrations: {
bad: {
source: "exec",
command: "${node}",
args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "bad-trust-secrets")],
},
});
const registry = loadTestRegistry(rootDir, "bad-trust-secrets");
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
});
@@ -748,38 +616,26 @@ describe("secret provider integration presets", () => {
() => {
const rootDir = makeTempDir();
const outsideDir = makeTempDir();
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.mkdirSync(path.join(rootDir, "bin"));
fs.writeFileSync(path.join(outsideDir, "resolve.mjs"), "process.stdin.resume();\n");
fs.symlinkSync(
path.join(outsideDir, "resolve.mjs"),
path.join(rootDir, "bin", "resolve.mjs"),
);
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "symlink-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "symlink-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "symlink-secrets")],
},
});
const registry = loadTestRegistry(rootDir, "symlink-secrets");
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
},
);
@@ -790,34 +646,22 @@ describe("secret provider integration presets", () => {
const linkedRoot = path.join(parentDir, "linked-plugin");
makeSecureDir(realRoot);
fs.symlinkSync(realRoot, linkedRoot, "dir");
fs.writeFileSync(path.join(realRoot, "index.ts"), "export default {};\n", "utf8");
makeSecureDir(path.join(realRoot, "bin"));
writeSecureFile(path.join(realRoot, "bin", "resolve.mjs"), "process.stdin.resume();\n");
fs.writeFileSync(
path.join(realRoot, "openclaw.plugin.json"),
JSON.stringify({
id: "linked-root-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs"],
},
writePluginManifest(realRoot, {
id: "linked-root-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(linkedRoot, "linked-root-secrets")],
},
});
const registry = loadTestRegistry(linkedRoot, "linked-root-secrets");
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
{
id: "vault",
@@ -845,36 +689,24 @@ describe("secret provider integration presets", () => {
() => {
const rootDir = makeTempDir();
const binDir = path.join(rootDir, "bin");
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
fs.mkdirSync(binDir);
fs.writeFileSync(path.join(binDir, "resolve.mjs"), "process.stdin.resume();\n");
fs.chmodSync(binDir, 0o777);
try {
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "writable-parent-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs"],
},
writePluginManifest(rootDir, {
id: "writable-parent-secrets",
secretProviderIntegrations: {
vault: {
providerAlias: "vault",
source: "exec",
command: "${node}",
args: ["./bin/resolve.mjs"],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
}),
"utf8",
);
const registry = loadPluginManifestRegistry({
candidates: [createCandidate(rootDir, "writable-parent-secrets")],
},
});
const registry = loadTestRegistry(rootDir, "writable-parent-secrets");
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
} finally {
fs.chmodSync(binDir, 0o700);
File diff suppressed because it is too large Load Diff