mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
+170
-598
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user