Files
openclaw/src/plugin-state/plugin-state-store.ts
Peter Steinberger 0b8aabe864 docs: document auth profile failure policy contract (#89613)
* docs: document markdown marker renderer

* docs: document rendered markdown chunking

* docs: document markdown text chunking

* docs: document shared text chunking

* docs: document plugin text chunking exports

* docs: document avatar policy constants

* docs: document node match candidates

* docs: document scoped expiring id cache

* docs: document runtime import normalization

* docs: document string sample summaries

* docs: document session usage timeseries types

* docs: document session usage response types

* docs: document manifest frontmatter shapes

* docs: document channel route input metadata

* docs: document pair loop guard settings

* docs: document migration config patch helpers

* docs: document api provider registry

* docs: document tool call repair payloads

* docs: document plugin tool payload helpers

* docs: document lazy promise loader

* docs: document store writer queue state

* docs: document thread binding lifecycle

* docs: document concurrency helper contract

* docs: document gateway client info contract

* docs: document delivery context contracts

* docs: document secret ref defaults contract

* docs: document command gating contract

* docs: document avatar policy contract

* docs: document node match policy

* docs: document message channel normalization

* docs: document boolean parsing contract

* docs: document zod parse helpers

* docs: document direct dm guard policy

* docs: document fixed window limiter contract

* docs: document node presence event contract

* docs: document secret normalization contract

* docs: document progress draft line removal

* docs: document usage formatting contracts

* docs: document agent run status contract

* docs: document runtime import helpers

* docs: document provider utility ownership

* docs: document invalid config helpers

* docs: document json compat parser

* docs: document channel config metadata ownership

* docs: document channel logging helpers

* docs: document sender identity validation ownership

* docs: document string sampling helper

* docs: document global singleton helpers

* docs: document transcript tool helpers

* docs: document exec safe-bin normalization

* docs: document reaction level resolver

* docs: document account snapshot redaction boundary

* docs: document messaging target helpers

* docs: document thread binding messages

* docs: document conversation binding context

* docs: document conversation resolution helper

* docs: document owner display secret retention

* docs: document provider request config types

* docs: document skills config types

* docs: document memory config types

* docs: document imessage config types

* docs: document crestodian config types

* docs: document tools config policies

* docs: document shared config base types

* docs: document channel config contracts

* docs: document openclaw config state types

* docs: document model config contracts

* docs: document shared agent config types

* docs: document agent defaults config types

* docs: document secret input contracts

* docs: document auth config contracts

* docs: document gateway config contracts

* docs: document tool call stream repair contracts

* docs: document memory host facades

* docs: document llm core contracts

* docs: document markdown core contracts

* docs: document gateway connect error contracts

* docs: document gateway protocol primitives

* docs: document gateway frame schemas

* docs: document gateway device schemas

* docs: document gateway environment schemas

* docs: document gateway push schemas

* docs: document gateway plugin schemas

* docs: document gateway artifact schemas

* docs: document gateway command schemas

* docs: document gateway task schemas

* docs: document gateway exec approval schemas

* docs: document gateway secret schemas

* docs: document gateway config schemas

* docs: document gateway snapshot schemas

* docs: document gateway chat schemas

* docs: document gateway wizard schemas

* docs: document gateway node schemas

* docs: document gateway plugin approval schemas

* docs: document gateway talk schemas

* docs: document gateway agent schemas

* docs: document gateway session schemas

* docs: document gateway cron schemas

* docs: document gateway agent model skill schemas

* docs: document gateway skill proposal tool schemas

* docs: document gateway protocol registry

* docs: document gateway channel status schemas

* docs: document gateway schema regression tests

* docs: document gateway schema barrel

* docs: document gateway validator tests

* docs: document gateway primitive push tests

* docs: document gateway contract tests

* docs: document native protocol guard

* docs: document channel schema tests

* docs: document gateway protocol smoke tests

* docs: document gateway protocol entrypoint

* docs: document gateway protocol type exports

* docs: document gateway error codes

* docs: document protocol schema registry

* docs: document talk audio codec

* docs: document talk activation names

* docs: document talk consult questions

* docs: document talk consult tool

* docs: document talk run control contracts

* docs: document talk run control adapter

* docs: document talkback consult queue

* docs: document talk consult transcript guard

* docs: document talk fast context runtime

* docs: document forced talk consult coordinator

* docs: document talk output activity tracker

* docs: document talk event metrics

* docs: document talk diagnostics

* docs: document talk observability hook

* docs: document talk provider resolver

* docs: document talk provider registry

* docs: document talk runtime primitives

* docs: document talk consult controller logs

* docs: document channel identity helpers

* docs: document channel account allowlist helpers

* docs: document channel metadata draft controls

* docs: document channel ingress policy

* docs: document channel sender access gates

* docs: document channel catalog message contracts

* docs: document channel account plugin helpers

* docs: document configured binding helpers

* docs: document channel acp approval config helpers

* docs: document channel bundled config write helpers

* docs: document channel plugin utility contracts

* docs: document channel config access helpers

* docs: document channel message action helpers

* docs: document channel outbound runtime helpers

* docs: document channel pairing promotion helpers

* docs: document channel registry helpers

* docs: document channel setup wizard helpers

* docs: document channel lifecycle status helpers

* docs: document channel target thread helpers

* docs: document channel session binding helpers

* docs: document channel package module probes

* docs: document channel setup wizard contracts

* docs: document channel plugin API barrels

* docs: document channel contract test helpers

* docs: document channel core helpers

* docs: document small core facades

* docs: document provider runtime helpers

* docs: document persistence and realtime helpers

* docs: document mcp and state helpers

* docs: document tool planner contracts

* docs: document music generation runtime

* docs: document crestodian command flow

* docs: document utility helpers

* docs: document node host helpers

* docs: document transcript contracts

* docs: document trajectory export contracts

* docs: document image generation contracts

* docs: document routing helper contracts

* docs: document session helper contracts

* docs: document video generation contracts

* docs: document model catalog contracts

* docs: document proxy capture contracts

* docs: document status rendering contracts

* docs: document test helper contracts

* docs: document wizard setup contracts

* docs: document process contracts

* docs: document memory host sdk contracts

* docs: document tts contracts

* docs: document secrets runtime contracts

* docs: document shared helper contracts

* docs: document hook runtime contracts

* docs: document security audit contracts

* docs: document flow contracts

* docs: document media understanding contracts

* docs: document tui contracts

* docs: document logging contracts

* docs: document llm contracts

* docs: document cron contracts

* docs: document daemon contracts

* docs: document task contracts

* docs: document acp contracts

* docs: document test utility contracts

* docs: document skill contracts

* docs: document config contracts

* docs: document outbound infra contracts

* docs: document command analysis contracts

* docs: document provider usage infra contracts

* docs: document file safety infra contracts

* docs: document exec approval infra contracts

* docs: document gateway runtime infra contracts

* docs: document infra utility contracts

* docs: document infra queue storage contracts

* docs: document heartbeat infra contracts

* docs: document remaining infra contracts

* docs: document gateway auth contracts

* docs: document gateway display helpers

* docs: document gateway http helpers

* docs: document gateway node helpers

* docs: document gateway mcp helpers

* docs: document gateway support helpers

* docs: document gateway server runtime helpers

* docs: document gateway runtime bootstrap helpers

* docs: document gateway session events

* docs: document gateway utility helpers

* docs: document gateway talk helpers

* docs: document gateway helper contracts

* docs: document gateway server method helpers

* docs: document gateway server auth helpers

* docs: document gateway server tests

* docs: document gateway test helpers

* docs: document gateway node tests

* docs: document gateway channel tests

* docs: document gateway session tests

* docs: document gateway server startup tests

* docs: document gateway tool test helpers

* docs: document gateway server test helpers

* docs: document gateway server method tests

* docs: document remaining gateway tests

* docs: document plugin sdk public subpaths

* docs: document plugin sdk runtime helpers

* docs: document plugin sdk memory provider helpers

* docs: document plugin sdk runtime facades

* docs: document plugin sdk command approval helpers

* docs: document plugin sdk runtime types

* docs: document plugin sdk browser account helpers

* docs: document plugin sdk media memory helpers

* docs: document plugin sdk core tests

* docs: document plugin sdk contract helpers

* docs: document plugin sdk test helpers

* docs: document remaining plugin sdk tests

* docs: document cli utility helpers

* docs: document cli runtime helpers

* docs: document cli command registration helpers

* docs: document node cli helpers

* docs: document cli program registration

* docs: document message cli registration

* docs: document daemon cli helpers

* docs: document cli route parsers
2026-06-03 15:20:39 -07:00

496 lines
15 KiB
TypeScript

import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
clearPluginStateDatabaseForTests,
closePluginStateDatabase,
MAX_PLUGIN_STATE_VALUE_BYTES,
pluginStateClear,
pluginStateConsume,
pluginStateDelete,
pluginStateEntries,
pluginStateLookup,
pluginStateRegister,
pluginStateRegisterIfAbsent,
pluginStateUpdate,
} from "./plugin-state-store.sqlite.js";
import type {
OpenKeyedStoreOptions,
PluginStateEntry,
PluginStateKeyedStore,
PluginStateSyncKeyedStore,
PluginStateStoreOperation,
} from "./plugin-state-store.types.js";
import { PluginStateStoreError } from "./plugin-state-store.types.js";
// Public plugin-state facade over the sqlite-backed store. It validates plugin
// ids, namespaces, JSON values, TTLs, and per-plugin limits before persistence.
export type {
OpenKeyedStoreOptions,
PluginStateEntry,
PluginStateKeyedStore,
PluginStateSyncKeyedStore,
PluginStateStoreErrorCode,
PluginStateStoreOperation,
PluginStateStoreProbeResult,
PluginStateStoreProbeStep,
} from "./plugin-state-store.types.js";
export { PluginStateStoreError } from "./plugin-state-store.types.js";
export {
closePluginStateDatabase,
closePluginStateSqliteStore,
countPluginStateLiveEntries,
isPluginStateDatabaseOpen,
MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
probePluginStateStore,
setMaxPluginStateEntriesPerPluginForTests,
sweepExpiredPluginStateEntries,
} from "./plugin-state-store.sqlite.js";
const NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/iu;
const MAX_NAMESPACE_BYTES = 128;
const MAX_KEY_BYTES = 512;
const MAX_JSON_DEPTH = 64;
type StoreOptionSignature = {
maxEntries: number;
defaultTtlMs?: number;
};
type PreparedRegisterParams = {
key: string;
valueJson: string;
ttlMs?: number;
};
const namespaceOptionSignatures = new Map<string, StoreOptionSignature>();
const textEncoder = new TextEncoder();
function invalidInput(
message: string,
operation: PluginStateStoreOperation = "register",
): PluginStateStoreError {
return new PluginStateStoreError(message, {
code: "PLUGIN_STATE_INVALID_INPUT",
operation,
});
}
function assertMaxBytes(
label: string,
value: string,
max: number,
operation: PluginStateStoreOperation = "register",
): void {
if (textEncoder.encode(value).byteLength > max) {
throw invalidInput(`plugin state ${label} must be <= ${max} bytes`, operation);
}
}
function validateNamespace(value: string, operation: PluginStateStoreOperation = "open"): string {
const trimmed = value.trim();
if (!NAMESPACE_PATTERN.test(trimmed)) {
throw invalidInput(`plugin state namespace must be a safe path segment: ${value}`, operation);
}
assertMaxBytes("namespace", trimmed, MAX_NAMESPACE_BYTES, operation);
return trimmed;
}
function validateKey(value: string, operation: PluginStateStoreOperation = "register"): string {
const trimmed = value.trim();
if (!trimmed) {
throw invalidInput("plugin state entry key must not be empty", operation);
}
assertMaxBytes("entry key", trimmed, MAX_KEY_BYTES, operation);
return trimmed;
}
function validateMaxEntries(value: number): number {
if (!Number.isInteger(value) || value < 1) {
throw invalidInput("plugin state maxEntries must be an integer >= 1", "open");
}
return value;
}
function validateOptionalTtlMs(
value: number | undefined,
operation: PluginStateStoreOperation = "register",
): number | undefined {
if (value == null) {
return undefined;
}
if (!Number.isSafeInteger(value) || value < 1) {
throw invalidInput("plugin state ttlMs must be a positive integer", operation);
}
return value;
}
function assertPlainJsonValue(
value: unknown,
seen: WeakSet<object>,
path: string,
depth = 0,
): void {
if (depth > MAX_JSON_DEPTH) {
throw new PluginStateStoreError(
`plugin state value nesting exceeds maximum depth of ${MAX_JSON_DEPTH}`,
{ code: "PLUGIN_STATE_LIMIT_EXCEEDED", operation: "register" },
);
}
if (value === null) {
return;
}
const valueType = typeof value;
if (valueType === "string" || valueType === "boolean") {
return;
}
if (valueType === "number") {
if (!Number.isFinite(value)) {
throw invalidInput(`plugin state value at ${path} must be a finite number`);
}
return;
}
if (valueType !== "object") {
throw invalidInput(`plugin state value at ${path} must be JSON-serializable`);
}
const objectValue = value as object;
if (seen.has(objectValue)) {
throw invalidInput(`plugin state value at ${path} must not contain circular references`);
}
seen.add(objectValue);
try {
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
if (!(index in value)) {
throw invalidInput(`plugin state array at ${path} must not be sparse`);
}
assertPlainJsonValue(value[index], seen, `${path}[${index}]`, depth + 1);
}
return;
}
if (Object.getPrototypeOf(objectValue) !== Object.prototype) {
throw invalidInput(`plugin state object at ${path} must be a plain object`);
}
// Reject accessors, symbols, sparse arrays, and non-enumerable state so stored
// values cannot execute code or round-trip differently through JSON.
const descriptorEntries = Object.entries(Object.getOwnPropertyDescriptors(objectValue));
const enumerableKeys = Object.keys(objectValue);
if (Object.getOwnPropertySymbols(objectValue).length > 0) {
throw invalidInput(`plugin state object at ${path} must not use symbol keys`);
}
if (descriptorEntries.length !== enumerableKeys.length) {
throw invalidInput(`plugin state object at ${path} must not use non-enumerable properties`);
}
for (const [key, descriptor] of descriptorEntries) {
if (descriptor.get || descriptor.set || !("value" in descriptor)) {
throw invalidInput(`plugin state object at ${path}.${key} must use data properties`);
}
assertPlainJsonValue(descriptor.value, seen, `${path}.${key}`, depth + 1);
}
} finally {
seen.delete(objectValue);
}
}
function assertJsonSerializable(value: unknown): void {
assertPlainJsonValue(value, new WeakSet<object>(), "value");
}
function assertValueSize(json: string): void {
if (textEncoder.encode(json).byteLength > MAX_PLUGIN_STATE_VALUE_BYTES) {
throw new PluginStateStoreError("plugin state value exceeds 64KB limit", {
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
operation: "register",
});
}
}
function prepareRegisterParams(
key: string,
value: unknown,
defaultTtlMs?: number,
opts?: { ttlMs?: number },
): PreparedRegisterParams {
const normalizedKey = validateKey(key, "register");
assertJsonSerializable(value);
const json = JSON.stringify(value);
if (json === undefined) {
throw invalidInput("plugin state value must be JSON-serializable", "register");
}
assertValueSize(json);
const ttlMs = validateOptionalTtlMs(opts?.ttlMs, "register") ?? defaultTtlMs;
return {
key: normalizedKey,
valueJson: json,
...(ttlMs != null ? { ttlMs } : {}),
};
}
function assertConsistentOptions(
pluginId: string,
namespace: string,
signature: StoreOptionSignature,
): void {
const key = `${pluginId}\0${namespace}`;
const existing = namespaceOptionSignatures.get(key);
if (!existing) {
namespaceOptionSignatures.set(key, signature);
return;
}
if (
existing.maxEntries !== signature.maxEntries ||
existing.defaultTtlMs !== signature.defaultTtlMs
) {
// A namespace is a shared storage contract. Reopening it with different
// limits would make eviction/TTL behavior depend on call order.
throw invalidInput(
`plugin state namespace ${namespace} for ${pluginId} was reopened with incompatible options`,
"open",
);
}
}
function createKeyedStoreForPluginId<T>(
pluginId: string,
options: OpenKeyedStoreOptions,
): PluginStateKeyedStore<T> {
const namespace = validateNamespace(options.namespace);
const maxEntries = validateMaxEntries(options.maxEntries);
const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs);
const env = options.env;
assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs });
return {
async register(key, value, opts) {
const params = prepareRegisterParams(key, value, defaultTtlMs, opts);
pluginStateRegister({
pluginId,
namespace,
key: params.key,
valueJson: params.valueJson,
maxEntries,
...(env ? { env } : {}),
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
});
},
async registerIfAbsent(key, value, opts) {
const params = prepareRegisterParams(key, value, defaultTtlMs, opts);
return pluginStateRegisterIfAbsent({
pluginId,
namespace,
key: params.key,
valueJson: params.valueJson,
maxEntries,
...(env ? { env } : {}),
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
});
},
async update(key, updateValue, opts) {
const normalizedKey = validateKey(key, "register");
return pluginStateUpdate({
pluginId,
namespace,
key: normalizedKey,
maxEntries,
updateValueJson: (current) => {
const next = updateValue(current as T | undefined);
if (next === undefined) {
return undefined;
}
const params = prepareRegisterParams(normalizedKey, next, defaultTtlMs, opts);
return {
valueJson: params.valueJson,
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
};
},
...(env ? { env } : {}),
});
},
async lookup(key) {
const normalizedKey = validateKey(key, "lookup");
return pluginStateLookup({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
}) as T | undefined;
},
async consume(key) {
const normalizedKey = validateKey(key, "consume");
return pluginStateConsume({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
}) as T | undefined;
},
async delete(key) {
const normalizedKey = validateKey(key, "delete");
return pluginStateDelete({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
});
},
async entries() {
return pluginStateEntries({
pluginId,
namespace,
...(env ? { env } : {}),
}) as PluginStateEntry<T>[];
},
async clear() {
pluginStateClear({ pluginId, namespace, ...(env ? { env } : {}) });
},
};
}
function createSyncKeyedStoreForPluginId<T>(
pluginId: string,
options: OpenKeyedStoreOptions,
): PluginStateSyncKeyedStore<T> {
const namespace = validateNamespace(options.namespace);
const maxEntries = validateMaxEntries(options.maxEntries);
const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs);
const env = options.env;
assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs });
return {
register(key, value, opts) {
const params = prepareRegisterParams(key, value, defaultTtlMs, opts);
pluginStateRegister({
pluginId,
namespace,
key: params.key,
valueJson: params.valueJson,
maxEntries,
...(env ? { env } : {}),
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
});
},
registerIfAbsent(key, value, opts) {
const params = prepareRegisterParams(key, value, defaultTtlMs, opts);
return pluginStateRegisterIfAbsent({
pluginId,
namespace,
key: params.key,
valueJson: params.valueJson,
maxEntries,
...(env ? { env } : {}),
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
});
},
update(key, updateValue, opts) {
const normalizedKey = validateKey(key, "register");
return pluginStateUpdate({
pluginId,
namespace,
key: normalizedKey,
maxEntries,
updateValueJson: (current) => {
const next = updateValue(current as T | undefined);
if (next === undefined) {
return undefined;
}
const params = prepareRegisterParams(normalizedKey, next, defaultTtlMs, opts);
return {
valueJson: params.valueJson,
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
};
},
...(env ? { env } : {}),
});
},
lookup(key) {
const normalizedKey = validateKey(key, "lookup");
return pluginStateLookup({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
}) as T | undefined;
},
consume(key) {
const normalizedKey = validateKey(key, "consume");
return pluginStateConsume({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
}) as T | undefined;
},
delete(key) {
const normalizedKey = validateKey(key, "delete");
return pluginStateDelete({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
});
},
entries() {
return pluginStateEntries({
pluginId,
namespace,
...(env ? { env } : {}),
}) as PluginStateEntry<T>[];
},
clear() {
pluginStateClear({ pluginId, namespace, ...(env ? { env } : {}) });
},
};
}
/** Opens an async plugin-state namespace for a non-core plugin id. */
export function createPluginStateKeyedStore<T>(
pluginId: string,
options: OpenKeyedStoreOptions,
): PluginStateKeyedStore<T> {
if (pluginId.startsWith("core:")) {
throw invalidInput("Plugin ids starting with 'core:' are reserved for core consumers.", "open");
}
return createKeyedStoreForPluginId<T>(pluginId, options);
}
/** Opens a sync plugin-state namespace for a non-core plugin id. */
export function createPluginStateSyncKeyedStore<T>(
pluginId: string,
options: OpenKeyedStoreOptions,
): PluginStateSyncKeyedStore<T> {
if (pluginId.startsWith("core:")) {
throw invalidInput("Plugin ids starting with 'core:' are reserved for core consumers.", "open");
}
return createSyncKeyedStoreForPluginId<T>(pluginId, options);
}
/** Opens an async plugin-state namespace for a trusted core owner id. */
export function createCorePluginStateKeyedStore<T>(
options: OpenKeyedStoreOptions & { ownerId: `core:${string}` },
): PluginStateKeyedStore<T> {
return createKeyedStoreForPluginId<T>(options.ownerId, options);
}
/** Opens a sync plugin-state namespace for a trusted core owner id. */
export function createCorePluginStateSyncKeyedStore<T>(
options: OpenKeyedStoreOptions & { ownerId: `core:${string}` },
): PluginStateSyncKeyedStore<T> {
return createSyncKeyedStoreForPluginId<T>(options.ownerId, options);
}
/** Clears plugin-state rows and option signatures for tests. */
export function clearPluginStateStoreForTests(): void {
clearPluginStateDatabaseForTests();
namespaceOptionSignatures.clear();
}
/** Resets plugin-state module/database state for isolated tests. */
export function resetPluginStateStoreForTests(options: { closeDatabase?: boolean } = {}): void {
if (options.closeDatabase !== false) {
closePluginStateDatabase();
closeOpenClawStateDatabaseForTest();
}
namespaceOptionSignatures.clear();
}