openclaw / src /plugin-state /plugin-state-store.runtime.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
76289e7 verified
Raw
History Blame Contribute Delete
12.7 kB
// Plugin state runtime tests cover runtime-backed plugin state storage.
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveStateDir } from "../config/paths.js";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import type { PluginRecord } from "../plugins/registry-types.js";
import { createPluginRegistry } from "../plugins/registry.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import { resetPluginBlobStoreForTests, type OpenBlobStoreOptions } from "./plugin-blob-store.js";
import { resetPluginStateStoreForTests } from "./plugin-state-store.js";
function createPluginRecord(
id: string,
origin: PluginRecord["origin"] = "bundled",
opts: { trustedOfficialInstall?: boolean } = {},
): PluginRecord {
return {
id,
name: id,
source: `/plugins/${id}/index.ts`,
origin,
trustedOfficialInstall: opts.trustedOfficialInstall,
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
cliBackendIds: [],
providerIds: [],
embeddingProviderIds: [],
speechProviderIds: [],
realtimeTranscriptionProviderIds: [],
realtimeVoiceProviderIds: [],
mediaUnderstandingProviderIds: [],
transcriptSourceProviderIds: [],
imageGenerationProviderIds: [],
videoGenerationProviderIds: [],
musicGenerationProviderIds: [],
webFetchProviderIds: [],
webSearchProviderIds: [],
migrationProviderIds: [],
agentHarnessIds: [],
cliCommands: [],
services: [],
gatewayDiscoveryServiceIds: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
} as PluginRecord;
}
function createTestPluginRegistry() {
return createPluginRegistry({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
runtime: {
state: {
resolveStateDir,
openBlobStore: () => {
throw new Error("registry plugin runtime proxy should bind openBlobStore");
},
openKeyedStore: () => {
throw new Error("registry plugin runtime proxy should bind openKeyedStore");
},
openSyncKeyedStore: () => {
throw new Error("registry plugin runtime proxy should bind openSyncKeyedStore");
},
},
} as unknown as PluginRuntime,
});
}
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
resetPluginBlobStoreForTests();
resetPluginStateStoreForTests();
});
describe("plugin runtime state proxy", () => {
it("binds openKeyedStore to the bundled plugin id and keeps resolveStateDir", async () => {
await withOpenClawTestState({ label: "plugin-state-runtime" }, async (state) => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("discord", "bundled");
registry.registry.plugins.push(record);
const api = registry.createApi(record, { config: {} });
expect(api.runtime.state.resolveStateDir()).toBe(state.stateDir);
const native = requireNodeSqlite();
const sql = [
vi.spyOn(native.DatabaseSync.prototype, "prepare"),
vi.spyOn(native.DatabaseSync.prototype, "exec"),
...(["get", "all", "run", "iterate"] as const).map((method) =>
vi.spyOn(native.StatementSync.prototype, method),
),
];
try {
const store = api.runtime.state.openKeyedStore<{ plugin: string }>({
namespace: "runtime",
maxEntries: 10,
});
await expect(store.registerIfAbsent("k", { plugin: "discord" })).resolves.toBe(true);
await expect(store.registerIfAbsent("k", { plugin: "duplicate" })).resolves.toBe(false);
const telegram = createPluginRecord("telegram", "bundled");
registry.registry.plugins.push(telegram);
const telegramApi = registry.createApi(telegram, { config: {} });
const telegramStore = telegramApi.runtime.state.openKeyedStore<{ plugin: string }>({
namespace: "runtime",
maxEntries: 10,
});
await expect(telegramStore.lookup("k")).resolves.toBeUndefined();
await expect(telegramStore.count?.()).resolves.toBe(0);
await expect(store.count?.()).resolves.toBe(1);
await expect(telegramStore.lookupMany?.(["k"])).resolves.toEqual([
{ ok: true, value: undefined },
]);
await expect(store.lookupMany?.(["k", "missing", "k"])).resolves.toEqual([
{ ok: true, value: { plugin: "discord" } },
{ ok: true, value: undefined },
{ ok: true, value: { plugin: "discord" } },
]);
await expect(store.lookup("k")).resolves.toEqual({ plugin: "discord" });
await store.register("temporary", { plugin: "discord" });
await expect(store.consume("temporary")).resolves.toEqual({ plugin: "discord" });
await store.register("deleted", { plugin: "discord" });
await expect(store.delete("deleted")).resolves.toBe(true);
await telegramStore.register("retained", { plugin: "telegram" });
await store.clear();
await expect(store.entries()).resolves.toEqual([]);
await expect(telegramStore.lookup("retained")).resolves.toEqual({ plugin: "telegram" });
for (const method of sql) {
expect(method).not.toHaveBeenCalled();
}
} finally {
sql.forEach((method) => method.mockRestore());
}
const syncStore = api.runtime.state.openSyncKeyedStore<{ plugin: string }>({
namespace: "sync-runtime",
maxEntries: 10,
});
expect(syncStore.registerIfAbsent("k", { plugin: "discord" })).toBe(true);
expect(syncStore.lookup("k")).toEqual({ plugin: "discord" });
expect(syncStore.lookupMany?.(["k", "missing"])).toEqual([
{ ok: true, value: { plugin: "discord" } },
{ ok: true, value: undefined },
]);
});
});
it("allows trusted official global plugins to use keyed state", async () => {
await withOpenClawTestState({ label: "plugin-state-trusted-global" }, async () => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("slack", "global", { trustedOfficialInstall: true });
registry.registry.plugins.push(record);
const api = registry.createApi(record, { config: {} });
const store = api.runtime.state.openKeyedStore<{ plugin: string }>({
namespace: "runtime",
maxEntries: 10,
});
await expect(store.register("thread", { plugin: "slack" })).resolves.toBeUndefined();
await expect(store.lookup("thread")).resolves.toEqual({ plugin: "slack" });
});
});
it("binds blob stores to the trusted plugin id", async () => {
await withOpenClawTestState({ label: "plugin-blob-runtime" }, async () => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("diffs", "global", { trustedOfficialInstall: true });
registry.registry.plugins.push(record);
const api = registry.createApi(record, { config: {} });
const store = api.runtime.state.openBlobStore<{ kind: string }>({
namespace: "runtime",
maxEntries: 10,
maxBytesPerEntry: 1024,
maxBytesPerNamespace: 4096,
});
await expect(
store.registerIfAbsent("viewer", new Uint8Array([1, 2, 3]), { kind: "viewer" }),
).resolves.toBe(true);
await expect(store.lookup("viewer")).resolves.toMatchObject({
key: "viewer",
metadata: { kind: "viewer" },
sizeBytes: 3,
});
const otherRecord = createPluginRecord("other", "bundled");
registry.registry.plugins.push(otherRecord);
const otherStore = registry
.createApi(otherRecord, { config: {} })
.runtime.state.openBlobStore<{ kind: string }>({
namespace: "runtime",
maxEntries: 10,
maxBytesPerEntry: 1024,
maxBytesPerNamespace: 4096,
});
await expect(otherStore.lookup("viewer")).resolves.toBeUndefined();
});
});
it("keeps blob and keyed namespace option policies independent", async () => {
await withOpenClawTestState({ label: "plugin-state-policy-independence" }, async () => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("diffs", "bundled");
registry.registry.plugins.push(record);
const state = registry.createApi(record, { config: {} }).runtime.state;
const blob = state.openBlobStore({
namespace: "shared-policy",
maxEntries: 2,
maxBytesPerEntry: 8,
maxBytesPerNamespace: 16,
overflowPolicy: "reject-new",
defaultTtlMs: 100,
});
const keyed = state.openKeyedStore({
namespace: "shared-policy",
maxEntries: 3,
overflowPolicy: "evict-oldest",
defaultTtlMs: 200,
});
await expect(blob.register("blob", new Uint8Array([1]), {})).resolves.toBeUndefined();
await expect(keyed.register("keyed", { ok: true })).resolves.toBeUndefined();
});
});
it("ignores plugin-supplied state directory overrides", async () => {
await withOpenClawTestState({ label: "plugin-blob-runtime-env" }, async (state) => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("diffs", "global", { trustedOfficialInstall: true });
registry.registry.plugins.push(record);
const api = registry.createApi(record, { config: {} });
const redirectedEnv = {
...state.env,
OPENCLAW_STATE_DIR: `${state.stateDir}-redirected`,
};
const store = api.runtime.state.openBlobStore<{ kind: string }>({
namespace: "runtime-env",
maxEntries: 10,
maxBytesPerEntry: 1024,
maxBytesPerNamespace: 4096,
env: redirectedEnv,
} as OpenBlobStoreOptions & { env: NodeJS.ProcessEnv });
await store.register("viewer", new Uint8Array([1]), { kind: "viewer" });
resetPluginBlobStoreForTests();
const { db } = openOpenClawStateDatabase({ env: state.env });
expect(
db
.prepare(
`SELECT COUNT(*) AS count FROM plugin_blob_entries
WHERE plugin_id = ? AND namespace = ? AND entry_key = ?`,
)
.get("diffs", "runtime-env", "viewer"),
).toEqual({ count: 1 });
});
});
it("rejects external plugins in this release", () => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("external-plugin", "workspace");
registry.registry.plugins.push(record);
const api = registry.createApi(record, { config: {} });
expect(() =>
api.runtime.state.openKeyedStore({ namespace: "runtime", maxEntries: 10 }),
).toThrow("openKeyedStore is only available for trusted plugins");
expect(() =>
api.runtime.state.openSyncKeyedStore({ namespace: "runtime", maxEntries: 10 }),
).toThrow("openSyncKeyedStore is only available for trusted plugins");
expect(() =>
api.runtime.state.openBlobStore({
namespace: "runtime",
maxEntries: 10,
maxBytesPerEntry: 1024,
maxBytesPerNamespace: 4096,
}),
).toThrow("openBlobStore is only available for trusted plugins");
});
it("names the denied capability, plugin, and origin for channel ingress queues", () => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("slack", "config");
registry.registry.plugins.push(record);
const api = registry.createApi(record, { config: {} });
expect(() => api.runtime.state.openChannelIngressQueue()).toThrow(
/openChannelIngressQueue is only available for trusted plugins in this release\. Plugin "slack" loaded with origin "config"/,
);
});
it("rejects untrusted global plugins", () => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("diffs", "global");
registry.registry.plugins.push(record);
const api = registry.createApi(record, { config: {} });
expect(() =>
api.runtime.state.openKeyedStore({ namespace: "runtime", maxEntries: 10 }),
).toThrow("openKeyedStore is only available for trusted plugins");
expect(() =>
api.runtime.state.openBlobStore({
namespace: "runtime",
maxEntries: 10,
maxBytesPerEntry: 1024,
maxBytesPerNamespace: 4096,
}),
).toThrow("openBlobStore is only available for trusted plugins");
});
});