import { join, relative } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { runInNewContext } from "node:vm"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runWithSqliteBusyTimeout } from "../infra/sqlite-busy-timeout.js"; import { clearOpenClawDatabaseQuarantine, recordOpenClawDatabaseQuarantine, } from "../state/openclaw-quarantine-store.js"; import { closeOpenClawStateDatabaseByPath } from "../state/openclaw-state-db-cache.js"; import { OPENCLAW_STATE_SCHEMA_VERSION } from "../state/openclaw-state-db-contract.js"; import { clearOpenClawStateDatabaseOpenFailure, openOpenClawStateDatabase, recordOpenClawStateDatabaseOpenFailure, } from "../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { claimOpenClawStateOwnership } from "../state/openclaw-state-ownership-operations.js"; import { createOpenClawTestState, type OpenClawTestState, withOpenClawTestState, } from "../test-utils/openclaw-test-state.js"; import { createPluginStateKeyedStore, createPluginStateSyncKeyedStore, resetPluginStateStoreForTests, pluginStateEntriesInKeyRange, } from "./plugin-state-store.js"; import { closePluginStateDatabase, withPluginStateDatabaseReadOnly, } from "./plugin-state-store.sqlite.js"; let testState: OpenClawTestState | undefined; beforeAll(async () => { testState = await createOpenClawTestState({ label: "plugin-state-open-errors" }); }); beforeEach(() => testState?.applyEnv()); afterEach(() => resetPluginStateStoreForTests()); afterAll(async () => testState?.cleanup()); describe("plugin state open errors", () => { it.each(["decode", "sqlite-step"] as const)( "preserves %s failures and releases the listing cursor", async (failure) => { await withOpenClawTestState({ label: "plugin-state-entry-cursor" }, async () => { const store = createPluginStateSyncKeyedStore("discord", { namespace: "cursor", maxEntries: 10, }); store.register("a", { value: 1 }); store.register("b", { value: 2 }); const { db, path } = openOpenClawStateDatabase(); db.prepare("UPDATE plugin_state_entries SET value_json = ? WHERE entry_key = ?").run( "invalid first JSON", "a", ); if (failure === "sqlite-step") { // The listing index lets SQLite return the corrupt first row before // evaluating the second row's native JSON expression. db.exec(` ALTER TABLE plugin_state_entries RENAME TO plugin_state_source; CREATE VIEW plugin_state_entries AS SELECT plugin_id, namespace, entry_key, CASE WHEN entry_key = 'b' THEN json_extract('invalid SQL JSON', '$') ELSE value_json END AS value_json, created_at, expires_at FROM plugin_state_source; `); } for (const connection of ["warm", "readonly"]) { if (connection === "readonly") { closePluginStateDatabase(); } expect(() => store.entries()).toThrowError( expect.objectContaining({ code: failure === "decode" ? "PLUGIN_STATE_CORRUPT" : "PLUGIN_STATE_READ_FAILED", operation: "entries", path, cause: failure === "decode" ? expect.any(SyntaxError) : expect.objectContaining({ code: "ERR_SQLITE_ERROR", message: "malformed JSON", }), }), ); const writer = new DatabaseSync(path); try { writer.exec("PRAGMA busy_timeout = 0"); const table = failure === "decode" ? "plugin_state_entries" : "plugin_state_source"; writer.exec(`UPDATE ${table} SET created_at = created_at + 1`); // A leaked reader would pin this committed WAL and make TRUNCATE busy. expect(writer.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get()).toMatchObject({ busy: 0, log: 0, checkpointed: 0, }); } finally { writer.close(); } } }); }, ); it("reports the explicit readonly path when acquisition fails before the operation", async () => { await withOpenClawTestState({ label: "plugin-state-explicit-read-path" }, async (state) => { const explicitPath = join(state.stateDir, "explicit.sqlite"); const database = new DatabaseSync(explicitPath); database.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1}`); database.close(); const read = vi.fn(() => undefined); expect(explicitPath).not.toBe(resolveOpenClawStateSqlitePath(state.env)); expect(() => withPluginStateDatabaseReadOnly("lookup", read, { path: relative(process.cwd(), explicitPath), env: state.env, }), ).toThrow( expect.objectContaining({ code: "PLUGIN_STATE_OPEN_FAILED", operation: "lookup", path: explicitPath, }), ); expect(read).not.toHaveBeenCalled(); }); }); it("reports the opened database path for corrupt values with an explicit env", async () => { await withOpenClawTestState( { label: "plugin-state-corrupt-explicit-env", applyEnv: false }, async (state) => { const options = { namespace: "corrupt-env", maxEntries: 10, env: state.env }; const sync = createPluginStateSyncKeyedStore<{ owner: string }>("discord", options); const store = createPluginStateKeyedStore<{ owner: string }>("discord", options); sync.register("key", { owner: "custom" }); const database = openOpenClawStateDatabase({ env: state.env }); expect(database.path).not.toBe(resolveOpenClawStateSqlitePath()); database.db .prepare("UPDATE plugin_state_entries SET value_json = ? WHERE namespace = ?") .run("invalid JSON", options.namespace); const expected = { code: "PLUGIN_STATE_CORRUPT", path: database.path, message: "Plugin state entry contains corrupt JSON.", }; for (const connection of ["warm", "readonly"]) { if (connection === "readonly") { closePluginStateDatabase(); } for (const read of [ () => sync.lookup("key"), () => store.lookup("key"), () => sync.entries(), () => store.entries(), () => pluginStateEntriesInKeyRange({ pluginId: "discord", namespace: options.namespace, keyStartInclusive: "key", keyEndExclusive: "kez", limit: 1, env: state.env, }), ]) { await expect((async () => await read())()).rejects.toMatchObject(expected); } expect(sync.lookupMany(["key"])).toEqual([ { ok: false, error: expect.objectContaining({ ...expected, operation: "lookup" }) }, ]); await expect(store.lookupMany(["key"])).resolves.toEqual([ { ok: false, error: expect.objectContaining({ ...expected, operation: "lookup" }) }, ]); expect(sync.count()).toBe(1); await expect(store.count()).resolves.toBe(1); } let callbackCalled = false; for (const stateStore of [sync, store]) { const readers = [ { operation: "consume", read: () => stateStore.consume("key") }, { operation: "lookup", read: () => stateStore.update("key", () => { callbackCalled = true; return { owner: "changed" }; }), }, { operation: "delete", read: () => stateStore.deleteIf("key", () => { callbackCalled = true; return true; }), }, ]; for (const { read, operation } of readers) { await expect((async () => await read())()).rejects.toMatchObject({ ...expected, operation, }); } } expect(callbackCalled).toBe(false); expect( openOpenClawStateDatabase({ env: state.env }) .db.prepare("SELECT value_json FROM plugin_state_entries WHERE namespace = ?") .get(options.namespace), ).toEqual({ value_json: "invalid JSON" }); }, ); }); it("keeps warm ownership denials distinct from acquisition failures for the same path", async () => { // A different open database must not make this fixture's closed path look warm. openOpenClawStateDatabase(); await withOpenClawTestState({ label: "plugin-state-ownership-errors" }, async () => { const options = { namespace: "ownership", maxEntries: 10 }; const store = createPluginStateKeyedStore("discord", options); const syncStore = createPluginStateSyncKeyedStore("discord", options); await store.register("k", { version: 1 }); claimOpenClawStateOwnership("fixture-supervisor", { env: { ...process.env, OPENCLAW_SUPERVISOR_MODE: "external" }, }); for (const code of ["PLUGIN_STATE_WRITE_FAILED", "PLUGIN_STATE_OPEN_FAILED"]) { expect(() => syncStore.register("k", { version: 2 })).toThrowError( expect.objectContaining({ code, operation: "register" }), ); await expect(store.register("k", { version: 2 })).rejects.toMatchObject({ code, operation: "register", }); await expect(store.lookup("k")).resolves.toEqual({ version: 1 }); if (code === "PLUGIN_STATE_WRITE_FAILED") { expect(closeOpenClawStateDatabaseByPath(resolveOpenClawStateSqlitePath())).toBe(true); } } }); }); it("keeps transaction lock contention distinct from database-open failures", () => { const store = createPluginStateSyncKeyedStore("discord", { namespace: "write-contention", maxEntries: 10, }); store.register("k", { version: 1 }); const database = openOpenClawStateDatabase(); const blocker = new DatabaseSync(database.path); try { blocker.exec("BEGIN IMMEDIATE"); expect(() => runWithSqliteBusyTimeout(database.db, 0, () => store.register("k", { version: 2 })), ).toThrowError( expect.objectContaining({ code: "PLUGIN_STATE_WRITE_FAILED", operation: "register", message: "Failed to register plugin state entry.", }), ); } finally { blocker.close(); } expect(store.lookup("k")).toEqual({ version: 1 }); }); it("fails closed for process-local and persisted database quarantine", async () => { const store = createPluginStateKeyedStore("discord", { namespace: "quarantine", maxEntries: 10, }); await store.register("k", { ok: true }); const databasePath = resolveOpenClawStateSqlitePath(testState?.env); closePluginStateDatabase(); recordOpenClawStateDatabaseOpenFailure(databasePath, new Error("latched failure")); await expect(store.lookup("k")).rejects.toMatchObject({ code: "PLUGIN_STATE_OPEN_FAILED", path: databasePath, message: "Failed to open the plugin state database.", }); await expect(store.count()).rejects.toMatchObject({ code: "PLUGIN_STATE_OPEN_FAILED", operation: "count", path: databasePath, message: "Failed to open the plugin state database.", }); clearOpenClawStateDatabaseOpenFailure(databasePath); expect( recordOpenClawDatabaseQuarantine({ env: testState?.env, kind: "state", path: databasePath, reason: "persisted failure", }), ).toBe(true); try { for (const operation of [ () => store.lookup("k"), () => store.lookupMany(["k"]), () => store.register("k", { ok: true }), ]) { await expect(operation()).rejects.toMatchObject({ code: "PLUGIN_STATE_OPEN_FAILED", path: databasePath, message: "Failed to open the plugin state database.\nDatabase integrity verification failed. Restore or repair the state database, then run openclaw doctor --fix.", }); } } finally { clearOpenClawStateDatabaseOpenFailure(databasePath); expect(clearOpenClawDatabaseQuarantine(databasePath, { env: testState?.env })).toBe(true); } }); it("fails closed for a newer shared-state schema", async () => { const store = createPluginStateKeyedStore("discord", { namespace: "newer-schema", maxEntries: 10, }); await store.register("k", { ok: true }); const databasePath = resolveOpenClawStateSqlitePath(testState?.env); openOpenClawStateDatabase().db.exec( `PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION + 1};`, ); closePluginStateDatabase(); try { for (const operation of [ () => store.lookup("k"), () => store.lookupMany(["k"]), () => store.register("k", { ok: true }), ]) { await expect(operation()).rejects.toMatchObject({ code: "PLUGIN_STATE_OPEN_FAILED", path: databasePath, message: "Failed to open the plugin state database.\nThe state database uses a newer schema. Run an OpenClaw build that supports it.", }); } } finally { clearOpenClawStateDatabaseOpenFailure(databasePath); const database = new DatabaseSync(databasePath); try { database.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`); } finally { database.close(); } } }); }); describe("plugin state JSON input", () => { it.each([ ["class instance", "new (class Entry { value = 1; })()"], ["custom prototype", "Object.create({ inherited: true })"], ["null prototype", "Object.create(null)"], [ "forged root constructor", "Object.create(Object.create(null, { constructor: { value: Object } }))", ], [ "constructor accessor", "Object.create(Object.create(null, { constructor: { get() { onAccess(); return Object; } } }))", ], ["accessor", "({ get value() { onAccess(); return 1; } })"], ["symbol key", "({ [Symbol('hidden')]: 1 })"], ["non-enumerable key", "Object.defineProperty({}, 'hidden', { value: 1 })"], ])( "rejects nested VM realm %s without replacing keyed state or invoking getters", async (_shape, expression) => { await withOpenClawTestState({ label: "plugin-state-json-input" }, async () => { try { const store = createPluginStateKeyedStore("discord", { namespace: "realm-shapes", maxEntries: 1, }); await store.register("retained", "original"); const onAccess = vi.fn(); const value: unknown = runInNewContext(`({ nested: [${expression}] })`, { onAccess }); await expect(store.register("retained", value)).rejects.toMatchObject({ code: "PLUGIN_STATE_INVALID_INPUT", operation: "register", }); expect(onAccess).not.toHaveBeenCalled(); await expect(store.lookup("retained")).resolves.toBe("original"); } finally { resetPluginStateStoreForTests(); } }); }, ); });