// 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"); }); });