Compare commits

...

1 Commits

Author SHA1 Message Date
Vincent Koc
c283167ac0 fix(plugin-sdk): bind keyed stores to plugin runtime 2026-05-03 01:24:07 -07:00
19 changed files with 197 additions and 221 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc.
- Plugins/SDK: allow plugins to use `api.runtime.state.openKeyedStore` with the store owner bound internally to the runtime plugin id and no plugin-controlled namespace.
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.
- Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try `@beta` first and fall back to default/latest when no plugin beta release exists.

View File

@@ -1,2 +1,2 @@
f829dd720df7c9c8eb9d59eda3b3f879bff278f74b4c00d8d788c1483865b649 plugin-sdk-api-baseline.json
1b3504c8f9ddd00801f095f94f417d469b47370064478eae389d33f4b8e10c76 plugin-sdk-api-baseline.jsonl
5f8788711b083737a113a099d56fc2d69322640b86cfa55359c377f0f8800071 plugin-sdk-api-baseline.json
dbb89462d8553b5c1071f46a9e34e04fa3f42a0cfee2f306d498e8587feedf19 plugin-sdk-api-baseline.jsonl

View File

@@ -411,7 +411,6 @@ Provider and channel execution paths must use the active runtime config snapshot
```typescript
const stateDir = api.runtime.state.resolveStateDir(process.env);
const store = api.runtime.state.openKeyedStore<MyRecord>({
namespace: "my-feature",
maxEntries: 200,
defaultTtlMs: 15 * 60_000,
});
@@ -422,11 +421,9 @@ Provider and channel execution paths must use the active runtime config snapshot
await store.clear();
```
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. Limits: `maxEntries` per namespace, 1,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry.
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. The SDK does not accept an owner id or namespace, so one plugin cannot read, write, list, consume, or clear another plugin's state. Limits: default 100 entries, optional `maxEntries` up to 1,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry.
<Warning>
Bundled plugins only in this release.
</Warning>
Omit `maxEntries` to use the default row budget.
</Accordion>
<Accordion title="api.runtime.tools">

View File

@@ -3,10 +3,10 @@ import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
import { getOptionalDiscordRuntime } from "./runtime.js";
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
const PERSISTENT_COMPONENT_NAMESPACE = "discord.components";
const PERSISTENT_MODAL_NAMESPACE = "discord.modals";
const PERSISTENT_COMPONENT_MAX_ENTRIES = 500;
const PERSISTENT_MODAL_MAX_ENTRIES = 500;
const PERSISTENT_COMPONENT_KEY_PREFIX = "component";
const PERSISTENT_MODAL_KEY_PREFIX = "modal";
const DISCORD_COMPONENT_ENTRIES_KEY = Symbol.for("openclaw.discord.componentEntries");
const DISCORD_MODAL_ENTRIES_KEY = Symbol.for("openclaw.discord.modalEntries");
@@ -76,7 +76,6 @@ function getPersistentComponentStore(): DiscordRegistryStore<DiscordComponentEnt
persistentComponentStore = runtime.state.openKeyedStore<
PersistedDiscordRegistryEntry<DiscordComponentEntry>
>({
namespace: PERSISTENT_COMPONENT_NAMESPACE,
maxEntries: PERSISTENT_COMPONENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
});
@@ -102,7 +101,6 @@ function getPersistentModalStore(): DiscordRegistryStore<DiscordModalEntry> | un
persistentModalStore = runtime.state.openKeyedStore<
PersistedDiscordRegistryEntry<DiscordModalEntry>
>({
namespace: PERSISTENT_MODAL_NAMESPACE,
maxEntries: PERSISTENT_MODAL_MAX_ENTRIES,
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
});
@@ -178,6 +176,7 @@ function readPersistedRegistryEntry<T extends { id: string }>(
function registerPersistentRegistryEntries<T extends { id: string }>(params: {
entries: T[];
ttlMs: number;
keyPrefix: string;
openStore: () => DiscordRegistryStore<T> | undefined;
}): void {
if (params.entries.length === 0) {
@@ -189,7 +188,7 @@ function registerPersistentRegistryEntries<T extends { id: string }>(params: {
}
for (const entry of params.entries) {
void store
.register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs })
.register(`${params.keyPrefix}:${entry.id}`, { version: 1, entry }, { ttlMs: params.ttlMs })
.catch(disablePersistentComponentRegistry);
}
}
@@ -202,24 +201,27 @@ function registerPersistentEntries(params: {
registerPersistentRegistryEntries({
entries: params.entries,
ttlMs: params.ttlMs,
keyPrefix: PERSISTENT_COMPONENT_KEY_PREFIX,
openStore: getPersistentComponentStore,
});
registerPersistentRegistryEntries({
entries: params.modals,
ttlMs: params.ttlMs,
keyPrefix: PERSISTENT_MODAL_KEY_PREFIX,
openStore: getPersistentModalStore,
});
}
function deletePersistentEntry<T extends { id: string }>(params: {
id: string;
keyPrefix: string;
openStore: () => DiscordRegistryStore<T> | undefined;
}): void {
const store = params.openStore();
if (!store) {
return;
}
void store.delete(params.id).catch(disablePersistentComponentRegistry);
void store.delete(`${params.keyPrefix}:${params.id}`).catch(disablePersistentComponentRegistry);
}
function resolveComponentConsumptionIds(entry: DiscordComponentEntry): string[] {
@@ -243,13 +245,16 @@ function deletePersistentComponentConsumptionGroup(entry: DiscordComponentEntry)
return;
}
for (const id of resolveComponentConsumptionIds(entry)) {
void store.delete(id).catch(disablePersistentComponentRegistry);
void store
.delete(`${PERSISTENT_COMPONENT_KEY_PREFIX}:${id}`)
.catch(disablePersistentComponentRegistry);
}
}
async function resolvePersistentRegistryEntry<T extends { id: string }>(params: {
id: string;
consume?: boolean;
keyPrefix: string;
openStore: () => DiscordRegistryStore<T> | undefined;
}): Promise<T | null> {
const store = params.openStore();
@@ -257,8 +262,8 @@ async function resolvePersistentRegistryEntry<T extends { id: string }>(params:
return null;
}
try {
const value =
params.consume === false ? await store.lookup(params.id) : await store.consume(params.id);
const key = `${params.keyPrefix}:${params.id}`;
const value = params.consume === false ? await store.lookup(key) : await store.consume(key);
return readPersistedRegistryEntry(value);
} catch (error) {
disablePersistentComponentRegistry(error);
@@ -315,6 +320,7 @@ export async function resolveDiscordComponentEntryWithPersistence(params: {
}
const persisted = await resolvePersistentRegistryEntry({
...params,
keyPrefix: PERSISTENT_COMPONENT_KEY_PREFIX,
openStore: getPersistentComponentStore,
});
if (persisted && params.consume !== false) {
@@ -337,12 +343,17 @@ export async function resolveDiscordModalEntryWithPersistence(params: {
const inMemory = resolveDiscordModalEntry(params);
if (inMemory) {
if (params.consume !== false) {
deletePersistentEntry({ ...params, openStore: getPersistentModalStore });
deletePersistentEntry({
...params,
keyPrefix: PERSISTENT_MODAL_KEY_PREFIX,
openStore: getPersistentModalStore,
});
}
return inMemory;
}
return await resolvePersistentRegistryEntry({
...params,
keyPrefix: PERSISTENT_MODAL_KEY_PREFIX,
openStore: getPersistentModalStore,
});
}

View File

@@ -178,35 +178,31 @@ describe("discord component registry", () => {
});
it("persists component and modal entries when runtime state is available", async () => {
const componentRegister = vi.fn().mockResolvedValue(undefined);
const modalRegister = vi.fn().mockResolvedValue(undefined);
const componentLookup = vi.fn().mockResolvedValue({
version: 1,
entry: { id: "btn_persisted", kind: "button", label: "Persisted" },
const register = vi.fn().mockResolvedValue(undefined);
const lookup = vi.fn(async (key: string) => {
if (key === "component:btn_persisted") {
return {
version: 1,
entry: { id: "btn_persisted", kind: "button", label: "Persisted" },
};
}
if (key === "modal:mdl_persisted") {
return {
version: 1,
entry: { id: "mdl_persisted", title: "Persisted", fields: [] },
};
}
return undefined;
});
const modalLookup = vi.fn().mockResolvedValue({
version: 1,
entry: { id: "mdl_persisted", title: "Persisted", fields: [] },
});
const componentStore = {
register: componentRegister,
lookup: componentLookup,
const store = {
register,
lookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
};
const modalStore = {
register: modalRegister,
lookup: modalLookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
};
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
opts.namespace === "discord.components" ? componentStore : modalStore,
);
const openKeyedStore = vi.fn(() => store);
const { setDiscordRuntime } = await import("./runtime.js");
setDiscordRuntime({
state: { openKeyedStore },
@@ -219,14 +215,14 @@ describe("discord component registry", () => {
ttlMs: 1000,
});
await vi.waitFor(() => expect(componentRegister).toHaveBeenCalledTimes(1));
expect(componentRegister).toHaveBeenCalledWith(
"btn_1",
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(2));
expect(register).toHaveBeenCalledWith(
"component:btn_1",
{ version: 1, entry: expect.objectContaining({ id: "btn_1" }) },
{ ttlMs: 1000 },
);
expect(modalRegister).toHaveBeenCalledWith(
"mdl_1",
expect(register).toHaveBeenCalledWith(
"modal:mdl_1",
{ version: 1, entry: expect.objectContaining({ id: "mdl_1" }) },
{ ttlMs: 1000 },
);
@@ -238,14 +234,14 @@ describe("discord component registry", () => {
await expect(
resolveDiscordModalEntryWithPersistence({ id: "mdl_persisted", consume: false }),
).resolves.toMatchObject({ id: "mdl_persisted" });
expect(componentLookup).toHaveBeenCalledWith("btn_persisted");
expect(modalLookup).toHaveBeenCalledWith("mdl_persisted");
expect(lookup).toHaveBeenCalledWith("component:btn_persisted");
expect(lookup).toHaveBeenCalledWith("modal:mdl_persisted");
expect(openKeyedStore).toHaveBeenCalledTimes(4);
});
it("deletes sibling persistent component entries when a group entry is consumed", async () => {
const componentDelete = vi.fn().mockResolvedValue(true);
const componentStore = {
const deleteEntry = vi.fn().mockResolvedValue(true);
const store = {
register: vi.fn(),
lookup: vi.fn(),
consume: vi.fn().mockResolvedValue({
@@ -258,17 +254,9 @@ describe("discord component registry", () => {
consumptionGroupEntryIds: ["btn_confirm", "btn_cancel"],
},
}),
delete: componentDelete,
delete: deleteEntry,
};
const modalStore = {
register: vi.fn(),
lookup: vi.fn(),
consume: vi.fn(),
delete: vi.fn(),
};
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
opts.namespace === "discord.components" ? componentStore : modalStore,
);
const openKeyedStore = vi.fn(() => store);
const { setDiscordRuntime } = await import("./runtime.js");
setDiscordRuntime({
state: { openKeyedStore },
@@ -282,8 +270,8 @@ describe("discord component registry", () => {
id: "btn_confirm",
});
await vi.waitFor(() => expect(componentDelete).toHaveBeenCalledWith("btn_cancel"));
expect(componentDelete).toHaveBeenCalledWith("btn_confirm");
await vi.waitFor(() => expect(deleteEntry).toHaveBeenCalledWith("component:btn_cancel"));
expect(deleteEntry).toHaveBeenCalledWith("component:btn_confirm");
});
it("falls back to the in-memory registry when persistent state cannot open", async () => {

View File

@@ -22,7 +22,6 @@ const MATRIX_APPROVAL_REACTION_ORDER = [
"deny",
] as const satisfies readonly ExecApprovalReplyDecision[];
const PERSISTENT_NAMESPACE = "matrix.approval-reactions";
const PERSISTENT_MAX_ENTRIES = 1000;
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
@@ -99,7 +98,6 @@ function getPersistentApprovalReactionStore(): MatrixApprovalReactionStore | und
}
try {
persistentStore = runtime.state.openKeyedStore<PersistedMatrixApprovalReactionTarget>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
});

View File

@@ -2,7 +2,6 @@ import { getOptionalMSTeamsRuntime } from "./runtime.js";
const TTL_MS = 24 * 60 * 60 * 1000;
const PERSISTENT_MAX_ENTRIES = 1000;
const PERSISTENT_NAMESPACE = "msteams.sent-messages";
const MSTEAMS_SENT_MESSAGES_KEY = Symbol.for("openclaw.msteamsSentMessages");
type MSTeamsSentMessageRecord = {
@@ -62,7 +61,6 @@ function getPersistentSentMessageStore(): MSTeamsSentMessageStore | undefined {
}
try {
persistentStore = runtime.state.openKeyedStore<MSTeamsSentMessageRecord>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: TTL_MS,
});

View File

@@ -10,7 +10,6 @@ import { getOptionalSlackRuntime } from "./runtime.js";
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const MAX_ENTRIES = 5000;
const PERSISTENT_MAX_ENTRIES = 1000;
const PERSISTENT_NAMESPACE = "slack.thread-participation";
type SlackThreadParticipationRecord = {
agentId?: string;
@@ -72,7 +71,6 @@ function getPersistentThreadParticipationStore(): SlackThreadParticipationStore
}
try {
persistentStore = runtime.state.openKeyedStore<SlackThreadParticipationRecord>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: TTL_MS,
});

View File

@@ -10,3 +10,8 @@ export * from "../plugins/lazy-service-module.js";
export * from "../plugins/types.js";
export { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
export type {
OpenKeyedStoreOptions,
PluginStateEntry,
PluginStateKeyedStore,
} from "../plugin-state/plugin-state-store.types.js";

View File

@@ -26,7 +26,6 @@ describe("runtime smoke", () => {
it("creates and exercises a keyed store directly", async () => {
await withOpenClawTestState({ label: "e2e-smoke-load" }, async () => {
const store = createPluginStateKeyedStore<{ ready: boolean }>("fixture-plugin", {
namespace: "boot",
maxEntries: 10,
});
expect(store).toBeDefined();
@@ -39,7 +38,6 @@ describe("runtime smoke", () => {
it("writes and reads a value", async () => {
await withOpenClawTestState({ label: "e2e-smoke-rw" }, async () => {
const store = createPluginStateKeyedStore<{ msg: string }>("fixture-plugin", {
namespace: "data",
maxEntries: 10,
});
await store.register("greeting", { msg: "hello" });
@@ -50,7 +48,6 @@ describe("runtime smoke", () => {
it("consumes a value exactly once", async () => {
await withOpenClawTestState({ label: "e2e-smoke-consume" }, async () => {
const store = createPluginStateKeyedStore<{ token: string }>("fixture-plugin", {
namespace: "tokens",
maxEntries: 10,
});
await store.register("one-shot", { token: "abc123" });
@@ -73,7 +70,6 @@ describe("persistence", () => {
it("survives close and reopen of the store", async () => {
await withOpenClawTestState({ label: "e2e-persist" }, async () => {
const storeA = createPluginStateKeyedStore<{ persisted: boolean }>("fixture-plugin", {
namespace: "durable",
maxEntries: 10,
});
await storeA.register("key1", { persisted: true });
@@ -84,7 +80,6 @@ describe("persistence", () => {
resetPluginStateStoreForTests();
const storeB = createPluginStateKeyedStore<{ persisted: boolean }>("fixture-plugin", {
namespace: "durable",
maxEntries: 10,
});
await expect(storeB.lookup("key1")).resolves.toEqual({ persisted: true });
@@ -103,7 +98,6 @@ describe("TTL", () => {
vi.setSystemTime(10_000);
const store = createPluginStateKeyedStore<{ v: number }>("fixture-plugin", {
namespace: "ttl-test",
maxEntries: 10,
});
await store.register("short", { v: 1 }, { ttlMs: 500 });
@@ -136,14 +130,12 @@ describe("TTL", () => {
// Isolation
// ---------------------------------------------------------------------------
describe("isolation", () => {
it("segregates plugins sharing namespace and key", async () => {
it("segregates plugins sharing a key", async () => {
await withOpenClawTestState({ label: "e2e-isolation" }, async () => {
const pluginA = createPluginStateKeyedStore<{ owner: string }>("plugin-a", {
namespace: "x",
maxEntries: 10,
});
const pluginB = createPluginStateKeyedStore<{ owner: string }>("plugin-b", {
namespace: "x",
maxEntries: 10,
});
@@ -153,7 +145,7 @@ describe("isolation", () => {
await expect(pluginA.lookup("same")).resolves.toEqual({ owner: "a" });
await expect(pluginB.lookup("same")).resolves.toEqual({ owner: "b" });
// Clearing one plugin's namespace does not affect the other.
// Clearing one plugin's store does not affect the other.
await pluginA.clear();
await expect(pluginA.lookup("same")).resolves.toBeUndefined();
await expect(pluginB.lookup("same")).resolves.toEqual({ owner: "b" });
@@ -168,7 +160,6 @@ describe("limits", () => {
it("accepts a value at the 64 KB boundary", async () => {
await withOpenClawTestState({ label: "e2e-limit-accept" }, async () => {
const store = createPluginStateKeyedStore<string>("fixture-plugin", {
namespace: "size",
maxEntries: 10,
});
// JSON.stringify wraps a string in quotes (+2 bytes).
@@ -182,7 +173,6 @@ describe("limits", () => {
it("rejects a value one byte over 64 KB", async () => {
await withOpenClawTestState({ label: "e2e-limit-reject" }, async () => {
const store = createPluginStateKeyedStore<string>("fixture-plugin", {
namespace: "size",
maxEntries: 10,
});
// 65 535 chars → 65 537 bytes of JSON → over limit.
@@ -193,41 +183,31 @@ describe("limits", () => {
});
});
it("enforces the per-plugin live-row cap", async () => {
it("evicts oldest entries at the plugin-wide live-row cap", async () => {
await withOpenClawTestState({ label: "e2e-limit-plugin" }, async () => {
// Spread MAX_ENTRIES_PER_PLUGIN rows across several namespaces so
// namespace eviction never fires (each namespace has generous room).
const nsCount = 10;
const perNs = MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN / nsCount; // 100
seedPluginStateEntriesForTests(
Array.from({ length: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN }, (_, index) => {
const ns = Math.floor(index / perNs);
const k = index % perNs;
return {
pluginId: "fixture-plugin",
namespace: `ns-${ns}`,
key: `k-${k}`,
value: { ns, k },
key: `k-${index}`,
value: { index },
};
}),
);
const store = createPluginStateKeyedStore("fixture-plugin", {
namespace: "ns-0",
maxEntries: perNs + 1,
maxEntries: MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
});
// One more row tips over the plugin-wide limit.
await expect(store.register("overflow", { boom: true })).rejects.toMatchObject({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});
await expect(store.register("overflow", { boom: true })).resolves.toBeUndefined();
await expect(store.lookup("k-0")).resolves.toBeUndefined();
await expect(store.lookup("overflow")).resolves.toEqual({ boom: true });
});
});
it("evicts oldest entries when namespace maxEntries is exceeded", async () => {
it("evicts oldest entries when maxEntries is exceeded", async () => {
await withOpenClawTestState({ label: "e2e-limit-eviction" }, async () => {
vi.useFakeTimers();
const store = createPluginStateKeyedStore<number>("fixture-plugin", {
namespace: "capped",
maxEntries: 3,
});
@@ -262,7 +242,6 @@ describe("failure safety", () => {
db.close();
const store = createPluginStateKeyedStore("fixture-plugin", {
namespace: "schema",
maxEntries: 10,
});
const error = await store.register("k", { ok: true }).catch((e: unknown) => e);
@@ -288,7 +267,6 @@ describe("failure safety", () => {
it("close and reopen cycle is clean", async () => {
await withOpenClawTestState({ label: "e2e-fail-reopen" }, async () => {
const store = createPluginStateKeyedStore<{ v: number }>("fixture-plugin", {
namespace: "reopen",
maxEntries: 10,
});
await store.register("k", { v: 1 });

View File

@@ -38,7 +38,6 @@ describe("plugin state permission hardening", () => {
try {
await withOpenClawTestState({ label: "plugin-state-post-commit-chmod" }, async () => {
const store = createPluginStateKeyedStore<{ value: number }>("fixture-plugin", {
namespace: "post-commit",
maxEntries: 10,
});
await store.register("first", { value: 1 });

View File

@@ -62,7 +62,6 @@ describe("plugin runtime state proxy", () => {
expect(api.runtime.state.resolveStateDir()).toBe(state.stateDir);
const store = api.runtime.state.openKeyedStore<{ plugin: string }>({
namespace: "runtime",
maxEntries: 10,
});
await store.register("k", { plugin: "discord" });
@@ -71,7 +70,6 @@ describe("plugin runtime state proxy", () => {
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();
@@ -79,14 +77,40 @@ describe("plugin runtime state proxy", () => {
});
});
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: {} });
it("allows workspace plugins and keeps owner isolation", async () => {
await withOpenClawTestState({ label: "plugin-state-runtime-workspace" }, async () => {
const registry = createTestPluginRegistry();
const external = createPluginRecord("external-state-plugin", "workspace");
registry.registry.plugins.push(external);
const api = registry.createApi(external, { config: {} });
expect(() =>
api.runtime.state.openKeyedStore({ namespace: "runtime", maxEntries: 10 }),
).toThrow("openKeyedStore is only available for bundled plugins");
const store = api.runtime.state.openKeyedStore<{ plugin: string }>({
maxEntries: 10,
});
await store.register("k", { plugin: "external-state-plugin" });
const sibling = createPluginRecord("sibling-state-plugin", "workspace");
registry.registry.plugins.push(sibling);
const siblingApi = registry.createApi(sibling, { config: {} });
const siblingStore = siblingApi.runtime.state.openKeyedStore<{ plugin: string }>({
maxEntries: 10,
});
await expect(siblingStore.lookup("k")).resolves.toBeUndefined();
await expect(store.lookup("k")).resolves.toEqual({ plugin: "external-state-plugin" });
});
});
it("uses a default row budget when options are omitted", async () => {
await withOpenClawTestState({ label: "plugin-state-runtime-defaults" }, async () => {
const registry = createTestPluginRegistry();
const record = createPluginRecord("external-plugin", "workspace");
registry.registry.plugins.push(record);
const api = registry.createApi(record, { config: {} });
const store = api.runtime.state.openKeyedStore<{ ok: boolean }>({});
await store.register("k", { ok: true });
await expect(store.lookup("k")).resolves.toEqual({ ok: true });
});
});
});

View File

@@ -4,7 +4,7 @@ import { closePluginStateSqliteStore, probePluginStateStore } from "./plugin-sta
export type PluginStateSeedEntry = {
pluginId: string;
namespace: string;
namespace?: string;
key: string;
value: unknown;
createdAt?: number;
@@ -50,7 +50,7 @@ export function seedPluginStateEntriesForTests(entries: PluginStateSeedEntry[]):
}
insertEntry.run({
plugin_id: entry.pluginId,
namespace: entry.namespace,
namespace: entry.namespace ?? "default",
entry_key: entry.key,
value_json: valueJson,
created_at: entry.createdAt ?? now + index,

View File

@@ -12,7 +12,6 @@ import {
sweepExpiredPluginStateEntries,
} from "./plugin-state-store.js";
import { resolvePluginStateDir, resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js";
import { seedPluginStateEntriesForTests } from "./plugin-state-store.test-helpers.js";
afterEach(() => {
vi.useRealTimers();
@@ -23,13 +22,11 @@ describe("plugin state keyed store", () => {
it("registers and looks up values across store instances", async () => {
await withOpenClawTestState({ label: "plugin-state-roundtrip" }, async () => {
const store = createPluginStateKeyedStore<{ count: number }>("discord", {
namespace: "components",
maxEntries: 10,
});
await store.register("interaction:1", { count: 1 });
const reopened = createPluginStateKeyedStore<{ count: number }>("discord", {
namespace: "components",
maxEntries: 10,
});
await expect(reopened.lookup("interaction:1")).resolves.toEqual({ count: 1 });
@@ -40,7 +37,6 @@ describe("plugin state keyed store", () => {
await withOpenClawTestState({ label: "plugin-state-upsert" }, async () => {
vi.useFakeTimers();
const store = createPluginStateKeyedStore<{ version: number }>("discord", {
namespace: "components",
maxEntries: 10,
});
vi.setSystemTime(1000);
@@ -61,7 +57,6 @@ describe("plugin state keyed store", () => {
it("returns undefined for missing lookups and consumes by deleting atomically", async () => {
await withOpenClawTestState({ label: "plugin-state-consume" }, async () => {
const store = createPluginStateKeyedStore<{ ok: boolean }>("discord", {
namespace: "components",
maxEntries: 10,
});
@@ -73,20 +68,20 @@ describe("plugin state keyed store", () => {
});
});
it("deletes and clears only the targeted namespace", async () => {
it("deletes and clears only the owning plugin store", async () => {
await withOpenClawTestState({ label: "plugin-state-clear" }, async () => {
const first = createPluginStateKeyedStore("discord", { namespace: "a", maxEntries: 10 });
const second = createPluginStateKeyedStore("discord", { namespace: "b", maxEntries: 10 });
await first.register("k1", { value: 1 });
await second.register("k2", { value: 2 });
const discord = createPluginStateKeyedStore("discord", { maxEntries: 10 });
const telegram = createPluginStateKeyedStore("telegram", { maxEntries: 10 });
await discord.register("k1", { value: 1 });
await telegram.register("k2", { value: 2 });
await expect(first.delete("k1")).resolves.toBe(true);
await expect(first.delete("k1")).resolves.toBe(false);
await first.register("k1", { value: 1 });
await first.clear();
await expect(discord.delete("k1")).resolves.toBe(true);
await expect(discord.delete("k1")).resolves.toBe(false);
await discord.register("k1", { value: 1 });
await discord.clear();
await expect(first.entries()).resolves.toEqual([]);
await expect(second.lookup("k2")).resolves.toEqual({ value: 2 });
await expect(discord.entries()).resolves.toEqual([]);
await expect(telegram.lookup("k2")).resolves.toEqual({ value: 2 });
});
});
@@ -95,7 +90,6 @@ describe("plugin state keyed store", () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const store = createPluginStateKeyedStore("discord", {
namespace: "ttl",
maxEntries: 10,
defaultTtlMs: 100,
});
@@ -113,7 +107,7 @@ describe("plugin state keyed store", () => {
it("evicts oldest live entries over maxEntries", async () => {
await withOpenClawTestState({ label: "plugin-state-eviction" }, async () => {
vi.useFakeTimers();
const store = createPluginStateKeyedStore("discord", { namespace: "evict", maxEntries: 2 });
const store = createPluginStateKeyedStore("discord", { maxEntries: 2 });
vi.setSystemTime(1000);
await store.register("a", 1);
vi.setSystemTime(2000);
@@ -125,48 +119,18 @@ describe("plugin state keyed store", () => {
});
});
it("rejects when the per-plugin live row ceiling would be exceeded without evicting siblings", async () => {
it("rejects maxEntries above the plugin-wide live row ceiling", async () => {
await withOpenClawTestState({ label: "plugin-state-plugin-limit" }, async () => {
seedPluginStateEntriesForTests([
...Array.from({ length: 999 }, (_, entryIndex) => ({
pluginId: "discord",
namespace: "limit",
key: `k-${entryIndex}`,
value: { namespaceIndex: 0, entryIndex },
})),
{
pluginId: "discord",
namespace: "sibling",
key: "k-0",
value: { namespaceIndex: 1, entryIndex: 0 },
},
]);
const limitStore = createPluginStateKeyedStore("discord", {
namespace: "limit",
maxEntries: 1_001,
});
const siblingStore = createPluginStateKeyedStore("discord", {
namespace: "sibling",
maxEntries: 10,
});
await expect(limitStore.register("overflow", { overflow: true })).rejects.toMatchObject({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});
await expect(siblingStore.lookup("k-0")).resolves.toEqual({
namespaceIndex: 1,
entryIndex: 0,
});
await expect(limitStore.lookup("overflow")).resolves.toBeUndefined();
expect(() => createPluginStateKeyedStore("discord", { maxEntries: 1_001 })).toThrow(
PluginStateStoreError,
);
});
});
it("segregates plugins sharing a namespace and key", async () => {
it("segregates plugins sharing a key", async () => {
await withOpenClawTestState({ label: "plugin-state-segregation" }, async () => {
const discord = createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 10 });
const discord = createPluginStateKeyedStore("discord", { maxEntries: 10 });
const telegram = createPluginStateKeyedStore("telegram", {
namespace: "same",
maxEntries: 10,
});
await discord.register("k", { plugin: "discord" });
@@ -178,16 +142,20 @@ describe("plugin state keyed store", () => {
});
});
it("validates namespaces, keys, options, and JSON values before writes", async () => {
it("validates keys, options, and JSON values before writes", async () => {
await withOpenClawTestState({ label: "plugin-state-validation" }, async () => {
expect(() =>
createPluginStateKeyedStore("discord", { namespace: "../bad", maxEntries: 10 }),
).toThrow(PluginStateStoreError);
expect(() =>
createPluginStateKeyedStore("discord", { namespace: "bad-max", maxEntries: 0 }),
).toThrow(PluginStateStoreError);
expect(() => createPluginStateKeyedStore("discord", { maxEntries: 0 })).toThrow(
PluginStateStoreError,
);
expect(() => createPluginStateKeyedStore("discord", { maxEntries: 1_001 })).toThrow(
PluginStateStoreError,
);
const store = createPluginStateKeyedStore("discord", { namespace: "valid", maxEntries: 10 });
const defaultStore = createPluginStateKeyedStore("default-plugin");
await defaultStore.register("default-options", { ok: true });
await expect(defaultStore.lookup("default-options")).resolves.toEqual({ ok: true });
const store = createPluginStateKeyedStore("discord", { maxEntries: 10 });
await expect(store.register(" ", { ok: true })).rejects.toThrow(PluginStateStoreError);
await expect(store.register("undefined", undefined)).rejects.toThrow(PluginStateStoreError);
await expect(store.register("infinity", Number.POSITIVE_INFINITY)).rejects.toThrow(
@@ -217,11 +185,6 @@ describe("plugin state keyed store", () => {
PluginStateStoreError,
);
// Namespace byte-length limit (128 bytes)
expect(() =>
createPluginStateKeyedStore("discord", { namespace: "a".repeat(129), maxEntries: 10 }),
).toThrow(PluginStateStoreError);
// JSON depth limit (64 levels)
let deep: unknown = { leaf: true };
for (let i = 0; i < 65; i += 1) {
@@ -243,12 +206,12 @@ describe("plugin state keyed store", () => {
});
});
it("rejects reopening the same namespace with incompatible options", async () => {
it("rejects reopening the same plugin store with incompatible options", async () => {
await withOpenClawTestState({ label: "plugin-state-option-consistency" }, async () => {
createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 10 });
expect(() =>
createPluginStateKeyedStore("discord", { namespace: "same", maxEntries: 11 }),
).toThrow(PluginStateStoreError);
createPluginStateKeyedStore("discord", { maxEntries: 10 });
expect(() => createPluginStateKeyedStore("discord", { maxEntries: 11 })).toThrow(
PluginStateStoreError,
);
});
});
@@ -256,20 +219,22 @@ describe("plugin state keyed store", () => {
await withOpenClawTestState({ label: "plugin-state-core" }, async () => {
const store = createCorePluginStateKeyedStore<{ stopped: boolean }>({
ownerId: "core:channel-intent",
namespace: "stopped",
maxEntries: 10,
});
await store.register("telegram:personal", { stopped: true });
await expect(store.lookup("telegram:personal")).resolves.toEqual({ stopped: true });
expect(() =>
createPluginStateKeyedStore("core:not-a-plugin", { namespace: "bad", maxEntries: 10 }),
).toThrow(PluginStateStoreError);
expect(() => createPluginStateKeyedStore("core:not-a-plugin", { maxEntries: 10 })).toThrow(
PluginStateStoreError,
);
expect(() => createPluginStateKeyedStore("__proto__", { maxEntries: 10 })).toThrow(
PluginStateStoreError,
);
});
});
it("closes the cached DB handle and reopens cleanly", async () => {
await withOpenClawTestState({ label: "plugin-state-close" }, async () => {
const store = createPluginStateKeyedStore("discord", { namespace: "close", maxEntries: 10 });
const store = createPluginStateKeyedStore("discord", { maxEntries: 10 });
await store.register("k", { ok: true });
closePluginStateSqliteStore();
await expect(store.lookup("k")).resolves.toEqual({ ok: true });
@@ -278,7 +243,7 @@ describe("plugin state keyed store", () => {
it.runIf(process.platform !== "win32")("hardens DB directory and file permissions", async () => {
await withOpenClawTestState({ label: "plugin-state-permissions" }, async () => {
const store = createPluginStateKeyedStore("discord", { namespace: "perms", maxEntries: 10 });
const store = createPluginStateKeyedStore("discord", { maxEntries: 10 });
await store.register("k", { ok: true });
expect(statSync(resolvePluginStateDir()).mode & 0o777).toBe(0o700);
@@ -303,7 +268,7 @@ describe("plugin state keyed store", () => {
db.exec("PRAGMA user_version = 2;");
db.close();
const store = createPluginStateKeyedStore("discord", { namespace: "schema", maxEntries: 10 });
const store = createPluginStateKeyedStore("discord", { maxEntries: 10 });
await expect(store.register("k", { ok: true })).rejects.toMatchObject({
code: "PLUGIN_STATE_SCHEMA_UNSUPPORTED",
});

View File

@@ -1,5 +1,7 @@
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import {
closePluginStateSqliteStore,
MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN,
MAX_PLUGIN_STATE_VALUE_BYTES,
pluginStateClear,
pluginStateConsume,
@@ -36,7 +38,10 @@ export {
const NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/iu;
const MAX_NAMESPACE_BYTES = 128;
const MAX_KEY_BYTES = 512;
const MAX_PLUGIN_ID_BYTES = 256;
const MAX_JSON_DEPTH = 64;
const DEFAULT_PLUGIN_STATE_NAMESPACE = "default";
const DEFAULT_PLUGIN_STATE_MAX_ENTRIES = 100;
type StoreOptionSignature = {
maxEntries: number;
@@ -85,11 +90,31 @@ function validateKey(value: string, operation: PluginStateStoreOperation = "regi
return trimmed;
}
function validateMaxEntries(value: number): number {
if (!Number.isInteger(value) || value < 1) {
throw invalidInput("plugin state maxEntries must be an integer >= 1", "open");
function validatePluginStatePluginId(value: string): string {
const trimmed = value.trim();
if (!trimmed || isBlockedObjectKey(trimmed)) {
throw invalidInput("plugin state plugin id must not be empty or reserved", "open");
}
return value;
assertMaxBytes("plugin id", trimmed, MAX_PLUGIN_ID_BYTES, "open");
if (trimmed.startsWith("core:")) {
throw invalidInput("Plugin ids starting with 'core:' are reserved for core consumers.", "open");
}
return trimmed;
}
function validateMaxEntries(value: number | undefined): number {
const resolved = value ?? DEFAULT_PLUGIN_STATE_MAX_ENTRIES;
if (
!Number.isInteger(resolved) ||
resolved < 1 ||
resolved > MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN
) {
throw invalidInput(
`plugin state maxEntries must be an integer from 1 to ${MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN}`,
"open",
);
}
return resolved;
}
function validateOptionalTtlMs(
@@ -202,7 +227,7 @@ function assertConsistentOptions(
existing.defaultTtlMs !== signature.defaultTtlMs
) {
throw invalidInput(
`plugin state namespace ${namespace} for ${pluginId} was reopened with incompatible options`,
`plugin state store for ${pluginId} was reopened with incompatible options`,
"open",
);
}
@@ -210,9 +235,9 @@ function assertConsistentOptions(
function createKeyedStoreForPluginId<T>(
pluginId: string,
options: OpenKeyedStoreOptions,
options: OpenKeyedStoreOptions = {},
): PluginStateKeyedStore<T> {
const namespace = validateNamespace(options.namespace);
const namespace = validateNamespace(DEFAULT_PLUGIN_STATE_NAMESPACE);
const maxEntries = validateMaxEntries(options.maxEntries);
const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs);
assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs });
@@ -256,12 +281,9 @@ function createKeyedStoreForPluginId<T>(
export function createPluginStateKeyedStore<T>(
pluginId: string,
options: OpenKeyedStoreOptions,
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);
return createKeyedStoreForPluginId<T>(validatePluginStatePluginId(pluginId), options);
}
export function createCorePluginStateKeyedStore<T>(

View File

@@ -15,8 +15,7 @@ export type PluginStateKeyedStore<T> = {
};
export type OpenKeyedStoreOptions = {
namespace: string;
maxEntries: number;
maxEntries?: number;
defaultTtlMs?: number;
};

View File

@@ -8,7 +8,11 @@ import {
passesManifestOwnerBasePolicy,
} from "./manifest-owner-policy.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import type {
PluginManifestContractListKey,
PluginManifestRecord,
PluginManifestRegistry,
} from "./manifest-registry.js";
import {
loadPluginRegistrySnapshot,
normalizePluginsConfigWithRegistry,
@@ -185,7 +189,7 @@ export function resolveExternalAuthProfileProviderPluginIds(params: {
}
function resolveRegistryManifestContractPluginIds(params: {
contract: string;
contract: PluginManifestContractListKey;
config?: PluginLoadOptions["config"];
workspaceDir?: string;
env?: PluginLoadOptions["env"];
@@ -205,10 +209,7 @@ function resolveRegistryManifestContractPluginIds(params: {
if (onlyPluginIdSet && !onlyPluginIdSet.has(plugin.id)) {
return false;
}
return (
(plugin.contracts?.[params.contract as keyof NonNullable<typeof plugin.contracts>] ?? [])
.length > 0
);
return (plugin.contracts?.[params.contract]?.length ?? 0) > 0;
})
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));

View File

@@ -2093,15 +2093,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const baseState = Reflect.get(target, prop, receiver);
return {
...baseState,
openKeyedStore: <T>(options: OpenKeyedStoreOptions): PluginStateKeyedStore<T> => {
const record =
pluginRuntimeRecordById.get(pluginId) ??
registry.plugins.find((entry) => entry.id === pluginId);
if (record?.origin !== "bundled") {
throw new Error(
"openKeyedStore is only available for bundled plugins in this release.",
);
}
openKeyedStore: <T>(options?: OpenKeyedStoreOptions): PluginStateKeyedStore<T> => {
return createPluginStateKeyedStore<T>(pluginId, options);
},
} satisfies PluginRuntime["state"];

View File

@@ -249,7 +249,7 @@ export type PluginRuntimeCore = {
state: {
resolveStateDir: typeof import("../../config/paths.js").resolveStateDir;
openKeyedStore: <T>(
options: import("../../plugin-state/plugin-state-store.types.js").OpenKeyedStoreOptions,
options?: import("../../plugin-state/plugin-state-store.types.js").OpenKeyedStoreOptions,
) => import("../../plugin-state/plugin-state-store.types.js").PluginStateKeyedStore<T>;
};
tasks: {