fix: allow plugin state values up to 1 MiB (#130387)

* fix: allow plugin state values up to 1 MiB

* test: type-check plugin state write rejection cases

* fix(ci): keep tar archive paths local on Windows
This commit is contained in:
Peter Steinberger
2026-08-26 14:33:01 -07:00
committed by GitHub
parent fa87acd0b8
commit 820d167d21
5 changed files with 65 additions and 35 deletions
+1 -1
View File
@@ -991,7 +991,7 @@ snapshots; OpenClaw owns all persistence and lifecycle coordination.
const blob = await blobs.lookup("artifact-1");
```
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. Use `registerIfAbsent(...)` for atomic dedupe claims: it returns `true` when the key was missing or expired and registered, or `false` when a live value already exists without overwriting its value, creation time, or TTL. Use `deleteIf(...)` when cleanup must remove only the value previously observed; its synchronous predicate and deletion run in one SQLite transaction. Limits: `maxEntries` per namespace, 50,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry. By default, a write at either row limit sheds the oldest live rows from the namespace being written; sibling namespaces are not evicted for that write, and the write still fails if the namespace cannot free enough rows. Set `overflowPolicy: "reject-new"` for durable ownership records that must never be evicted: new keys fail at either limit, while existing keys remain updateable.
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. Use `registerIfAbsent(...)` for atomic dedupe claims: it returns `true` when the key was missing or expired and registered, or `false` when a live value already exists without overwriting its value, creation time, or TTL. Use `deleteIf(...)` when cleanup must remove only the value previously observed; its synchronous predicate and deletion run in one SQLite transaction. Limits: `maxEntries` per namespace, 50,000 live rows per plugin, JSON values up to 1 MiB of UTF-8 encoded JSON, and optional TTL expiry. By default, a write at either row limit sheds the oldest live rows from the namespace being written; sibling namespaces are not evicted for that write, and the write still fails if the namespace cannot free enough rows. Set `overflowPolicy: "reject-new"` for durable ownership records that must never be evicted: new keys fail at either limit, while existing keys remain updateable.
`openSyncKeyedStore<T>(...)` returns the same store shape with synchronous methods (`register`, `registerIfAbsent`, `deleteIf`, `lookup`, `consume`, `clear` all return values directly instead of promises) for callers that cannot await.
+28 -12
View File
@@ -641,12 +641,18 @@ export async function prepareBundledAiRuntimePackage(
((tarballPath: string, destination: string) =>
// Source-ref validation runs this trusted harness outside the candidate's dependency tree.
// Keep extraction on the system tar contract so only the candidate checkout needs install.
run("tar", ["-xzf", tarballPath, "-C", destination, "--strip-components=1"], destination, {
timeoutMs: resolveTimeoutMs(
"OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS",
DEFAULT_PACKAGE_PACK_TIMEOUT_MS,
),
}));
// Use an archive basename so GNU tar cannot treat a Windows drive as a remote host.
run(
"tar",
["-xzf", path.basename(tarballPath), "-C", destination, "--strip-components=1"],
path.dirname(tarballPath),
{
timeoutMs: resolveTimeoutMs(
"OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS",
DEFAULT_PACKAGE_PACK_TIMEOUT_MS,
),
},
));
const prepareManifest = packageOptions.prepareManifest ?? (async () => false);
const restoreManifest = packageOptions.restoreManifest ?? (async () => false);
const originalPackageJson = await fs.readFile(packageJsonPath, "utf8");
@@ -831,7 +837,12 @@ async function normalizeOpenClawTarballModes(tarballPath: string) {
);
const stageDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-package-modes-"));
try {
await run("tar", ["-xzf", tarballPath, "-C", stageDir], stageDir, { timeoutMs });
await run(
"tar",
["-xzf", path.basename(tarballPath), "-C", stageDir],
path.dirname(tarballPath),
{ timeoutMs },
);
let stagedFileCount = 0;
const normalizeStagedModes = async (dir: string): Promise<void> => {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
@@ -855,11 +866,16 @@ async function normalizeOpenClawTarballModes(tarballPath: string) {
const stageRootEntries = await fs.readdir(stageDir);
const normalizedPath = `${tarballPath}.modes-tmp`;
await fs.rm(normalizedPath, { force: true });
await run("tar", ["-czf", normalizedPath, "-C", stageDir, ...stageRootEntries], stageDir, {
// macOS bsdtar must not add AppleDouble (._*) sidecar entries.
env: { ...process.env, COPYFILE_DISABLE: "1" },
timeoutMs,
});
await run(
"tar",
["-czf", path.basename(normalizedPath), "-C", stageDir, ...stageRootEntries],
path.dirname(normalizedPath),
{
// macOS bsdtar must not add AppleDouble (._*) sidecar entries.
env: { ...process.env, COPYFILE_DISABLE: "1" },
timeoutMs,
},
);
await fs.rename(normalizedPath, tarballPath);
} finally {
await fs.rm(stageDir, { force: true, recursive: true });
+31 -20
View File
@@ -6,6 +6,7 @@ import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
closePluginStateDatabase,
createPluginStateKeyedStore,
createPluginStateSyncKeyedStore,
resetPluginStateStoreForTests,
sweepExpiredPluginStateEntries,
} from "./plugin-state-store.js";
@@ -149,31 +150,41 @@ describe("isolation", () => {
// Limits
// ---------------------------------------------------------------------------
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", {
it.each(["async", "sync"])("enforces the 1 MiB boundary across %s writes", async (mode) => {
await withOpenClawTestState({ label: "e2e-limit" }, async () => {
const createStore =
mode === "async"
? createPluginStateKeyedStore<string>
: createPluginStateSyncKeyedStore<string>;
const store = createStore("fixture-plugin", {
namespace: "size",
maxEntries: 10,
});
// JSON.stringify wraps a string in quotes (+2 bytes).
// 65 534 chars → 65 536 bytes of JSON → exactly at limit.
const boundary = "x".repeat(65_534);
await expect(store.register("big", boundary)).resolves.toBeUndefined();
await expect(store.lookup("big")).resolves.toBe(boundary);
});
});
const boundary = "x".repeat(1_048_574);
const oversize = `${boundary}x`;
const update = expectDefined(store.update, "keyed store update support");
await store.register("registered", boundary);
expect(await store.registerIfAbsent("claimed", boundary)).toBe(true);
await store.register("updated", "before");
expect(await update("updated", () => boundary)).toBe(true);
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.
const oversize = "x".repeat(65_535);
await expect(store.register("big", oversize)).rejects.toMatchObject({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});
for (const write of [
() => store.register("registered", oversize),
() => store.registerIfAbsent("rejected", oversize),
() => update("updated", () => oversize),
]) {
await expect(async () => {
await write();
}).rejects.toMatchObject({
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});
}
resetPluginStateStoreForTests();
for (const key of ["registered", "claimed", "updated"]) {
expect(await store.lookup(key)).toBe(boundary);
}
expect(await store.lookup("rejected")).toBeUndefined();
});
});
});
@@ -30,7 +30,7 @@ import {
} from "./plugin-state-store.types.js";
// Plugin-wide fuse only; namespace maxEntries still owns normal cache eviction.
export const MAX_PLUGIN_STATE_VALUE_BYTES = 65_536;
export const MAX_PLUGIN_STATE_VALUE_BYTES = 1_048_576;
export const MAX_PLUGIN_STATE_ENTRIES_PER_PLUGIN = 50_000;
const PLUGIN_STATE_EXPIRY_BATCH_ROWS = 1_024;
let maxPluginStateEntriesPerPluginForTests: number | undefined;
+4 -1
View File
@@ -810,7 +810,10 @@ describe("plugin state keyed store", () => {
await expect(store.register("non-enumerable", nonEnumerable)).rejects.toThrow(
PluginStateStoreError,
);
await expectPluginStateStoreError(store.register("big", "x".repeat(65_537)), {
// UTF-8 bytes, including JSON quotes, determine the 1 MiB boundary.
const boundary = "é".repeat(524_287);
await expect(store.register("large", boundary)).resolves.toBeUndefined();
await expectPluginStateStoreError(store.register("big", `${boundary}x`), {
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
});