mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
refactor(diffs): move ephemeral artifacts to SQLite (#109328)
* refactor(diffs): move ephemeral artifacts to SQLite * fix(plugin-state): satisfy SDK validation gates * test(diffs): satisfy lint contracts * fix(plugin-state): count expired blobs toward quotas * fix(plugin-state): harden blob storage boundaries * fix(plugins): replace stale install provenance
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
e92bead6ecd7b79e789e67ef631a125aa9f69887e82112c99471932ebd578587 plugin-sdk-api-baseline.json
|
||||
ab0f1f57323d75ade752d6961834a94ddf9eb4fe78c334d43b7ddce26ea0d6c1 plugin-sdk-api-baseline.jsonl
|
||||
4b3a5f9dde74285c16fba05eb16e6fd5eaa00386143db82a00a9cf7bfa8fa9bb plugin-sdk-api-baseline.json
|
||||
6c650cf131c85bb8f67fb8c053124aa59c3062be24951e6e3ba45c00440d6f61 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -715,16 +715,32 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
await store.deleteIf?.("key-1", (current) => current.value === "hello");
|
||||
await store.consume("key-1");
|
||||
await store.clear();
|
||||
|
||||
const blobs = api.runtime.state.openBlobStore<MyBlobMetadata>({
|
||||
namespace: "rendered-artifacts",
|
||||
maxEntries: 100,
|
||||
maxBytesPerEntry: 4 * 1024 * 1024,
|
||||
maxBytesPerNamespace: 64 * 1024 * 1024,
|
||||
defaultTtlMs: 15 * 60_000,
|
||||
});
|
||||
await blobs.register(
|
||||
"artifact-1",
|
||||
new TextEncoder().encode("binary or text payload"),
|
||||
{ contentType: "text/plain" },
|
||||
);
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
`openBlobStore<TMetadata>(...)` stores bounded binary payloads in shared SQLite without base64 or file sidecars. It requires per-entry, per-namespace byte, and row limits; copies byte arrays at the API boundary; and lists metadata without loading every BLOB. `register(...)` is an explicit upsert, including for expired keys. `registerIfAbsent(...)` provides collision-safe creation: an expired key remains occupied until its owner claims it with `deleteExpiredKey(key)` or `deleteExpired()`, preserving metadata needed to remove related named artifacts after the SQLite commit. Any row with a TTL is transient and excluded from backup/restore even before it expires; omit TTL for durable, restorable state. Host fuses cap each BLOB at 100 MiB, each plugin at 512 MiB of physically stored BLOBs, and each plugin at 50,000 physically stored rows, including expired rows awaiting owner cleanup. Use `registerIfAbsent(...)` with `overflowPolicy: "reject-new"` when external materializations must not be silently orphaned by replacement or eviction.
|
||||
|
||||
`openChannelIngressQueue<TPayload>(...)` opens a persisted ingress queue scoped to the calling plugin, for buffering inbound events that need at-least-once processing across restarts. When stale-claim recovery uses `shouldRecover`, also provide `shouldRecoverCorrupt` if corrupt claimed payloads should be quarantined: its payload-independent claim identity lets the plugin preserve live owner and lane policy before the queue tombstones the row.
|
||||
|
||||
<Warning>
|
||||
`openKeyedStore`, `openSyncKeyedStore`, and `openChannelIngressQueue` are available only to bundled plugins and trusted official plugin installations in this release.
|
||||
`openBlobStore`, `openKeyedStore`, `openSyncKeyedStore`, and `openChannelIngressQueue` are available only to bundled plugins and trusted official plugin installations in this release.
|
||||
</Warning>
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1193,8 +1193,10 @@ sessionId})`; create, branch, continue, list, and fork flows live in their
|
||||
- Zalo hosted outbound media now uses shared SQLite `plugin_blob_entries`
|
||||
instead of `openclaw-zalo-outbound-media` JSON/bin temp sidecars.
|
||||
- Diffs viewer HTML and metadata now use shared SQLite `plugin_blob_entries`
|
||||
instead of `meta.json`/`viewer.html` temp files. Rendered PNG/PDF outputs stay
|
||||
temp materializations because channel delivery still needs a file path.
|
||||
instead of `meta.json`/`viewer.html` temp files. Viewer HTML is stored as a
|
||||
gzip blob and only the URL token hash is persisted. Rendered PNG/PDF outputs
|
||||
stay temp materializations because channel delivery still needs a file path;
|
||||
their expiry metadata is SQLite-owned without JSON sidecars.
|
||||
- Canvas managed documents now use shared SQLite `plugin_blob_entries` instead
|
||||
of a default `state/canvas/documents` directory. The Canvas host serves those
|
||||
blobs directly; local files are created only for explicit `host.root`
|
||||
|
||||
+4
-4
@@ -289,11 +289,11 @@ Supported `defaults` keys: `fontFamily`, `fontSize`, `lineSpacing`, `layout`, `s
|
||||
|
||||
## Artifact lifecycle and storage
|
||||
|
||||
- Artifacts live under `$TMPDIR/openclaw-diffs`.
|
||||
- Viewer metadata stores a random 20-hex-char artifact ID, a random 48-hex-char token, `createdAt`/`expiresAt`, and the stored `viewer.html` path.
|
||||
- Viewer HTML and metadata live in the shared `state/openclaw.sqlite` database under the Diffs plugin blob namespace. HTML is gzip-compressed; SQLite stores only a SHA-256 hash of the random URL token, not the token itself.
|
||||
- Rendered PNG/PDF files remain temporary materializations under `$TMPDIR/openclaw-diffs` because channel delivery requires a file path. SQLite owns their expiry metadata; no JSON sidecars are written.
|
||||
- Default artifact TTL: 30 minutes. Maximum accepted TTL: 6 hours.
|
||||
- Cleanup runs opportunistically after each artifact create call; expired artifacts are deleted.
|
||||
- Fallback sweep removes stale folders older than 24 hours when metadata is missing.
|
||||
- Cleanup runs opportunistically after each artifact create call. Expired SQLite rows are deleted first, followed by any corresponding PNG/PDF directory.
|
||||
- A fallback sweep removes rowless temporary folders older than 24 hours. Legacy `meta.json`, `file-meta.json`, and `viewer.html` caches are not imported or read.
|
||||
|
||||
## Viewer URL and network behavior
|
||||
|
||||
|
||||
@@ -220,7 +220,8 @@ diff --git a/src/example.ts b/src/example.ts
|
||||
- Multi-file patches start with a changed-files summary card: totals, per-file `+N`/`-N` stats, change badges, and anchor links.
|
||||
- Rendered PNG/PDF files keep the per-file header counts but omit the interactive view toggles.
|
||||
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
|
||||
- Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
|
||||
- Viewer HTML and metadata are ephemeral SQLite plugin blobs. The URL token is returned to the caller while SQLite stores only its SHA-256 hash.
|
||||
- Rendered PNG/PDF files remain temporary materializations in `$TMPDIR/openclaw-diffs` because delivery APIs require a file path. No JSON metadata sidecars are written or imported.
|
||||
- Default viewer URLs use loopback (`127.0.0.1`) unless you set plugin `viewerBaseUrl`, pass `baseUrl`, or use `gateway.bind=custom` + `gateway.customBindHost`.
|
||||
- If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw `127.0.0.1` viewer requests without forwarded client-IP headers fail closed by design.
|
||||
- In that topology, prefer `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set plugin `viewerBaseUrl` (or pass a proxy/public `baseUrl`) when you need a shareable viewer URL.
|
||||
|
||||
@@ -3,6 +3,11 @@ import fs from "node:fs/promises";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import type {
|
||||
PluginBlobEntry,
|
||||
PluginBlobEntryInfo,
|
||||
PluginBlobStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -319,6 +324,7 @@ describe("diffs plugin registration", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const blobStore = createMemoryBlobStore();
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
@@ -347,6 +353,7 @@ describe("diffs plugin registration", () => {
|
||||
config: {
|
||||
current: () => configFile,
|
||||
},
|
||||
state: { openBlobStore: () => blobStore },
|
||||
} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
@@ -445,6 +452,7 @@ describe("diffs plugin registration", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const blobStore = createMemoryBlobStore();
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
@@ -475,6 +483,7 @@ describe("diffs plugin registration", () => {
|
||||
config: {
|
||||
current: () => configFile,
|
||||
},
|
||||
state: { openBlobStore: () => blobStore },
|
||||
} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
@@ -601,6 +610,7 @@ describe("diffs plugin registration", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const blobStore = createMemoryBlobStore();
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
@@ -622,6 +632,7 @@ describe("diffs plugin registration", () => {
|
||||
config: {
|
||||
current: () => configFile,
|
||||
},
|
||||
state: { openBlobStore: () => blobStore },
|
||||
} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
@@ -672,6 +683,105 @@ describe("diffs plugin registration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createMemoryBlobStore<TMetadata>(): PluginBlobStore<TMetadata> {
|
||||
const entries = new Map<
|
||||
string,
|
||||
{
|
||||
bytes: Uint8Array;
|
||||
metadata: TMetadata;
|
||||
createdAt: number;
|
||||
expiresAt?: number;
|
||||
}
|
||||
>();
|
||||
const read = (key: string): PluginBlobEntry<TMetadata> | undefined => {
|
||||
const entry = entries.get(key);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) {
|
||||
entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
key,
|
||||
bytes: entry.bytes.slice(),
|
||||
metadata: entry.metadata,
|
||||
sizeBytes: entry.bytes.byteLength,
|
||||
createdAt: entry.createdAt,
|
||||
...(entry.expiresAt !== undefined ? { expiresAt: entry.expiresAt } : {}),
|
||||
};
|
||||
};
|
||||
const register: PluginBlobStore<TMetadata>["register"] = async (key, bytes, metadata, opts) => {
|
||||
const createdAt = Date.now();
|
||||
entries.set(key, {
|
||||
bytes: bytes.slice(),
|
||||
metadata,
|
||||
createdAt,
|
||||
...(opts?.ttlMs ? { expiresAt: createdAt + opts.ttlMs } : {}),
|
||||
});
|
||||
};
|
||||
return {
|
||||
register,
|
||||
async registerIfAbsent(key, bytes, metadata, opts) {
|
||||
if (read(key)) {
|
||||
return false;
|
||||
}
|
||||
await register(key, bytes, metadata, opts);
|
||||
return true;
|
||||
},
|
||||
async lookup(key) {
|
||||
return read(key);
|
||||
},
|
||||
async entries() {
|
||||
return [...entries.keys()].flatMap((key) => {
|
||||
const entry = read(key);
|
||||
if (!entry) {
|
||||
return [];
|
||||
}
|
||||
const { bytes: _bytes, ...info } = entry;
|
||||
return [info];
|
||||
});
|
||||
},
|
||||
async delete(key) {
|
||||
return entries.delete(key);
|
||||
},
|
||||
async deleteExpiredKey(key) {
|
||||
const entry = entries.get(key);
|
||||
if (!entry || entry.expiresAt === undefined || entry.expiresAt > Date.now()) {
|
||||
return undefined;
|
||||
}
|
||||
entries.delete(key);
|
||||
return {
|
||||
key,
|
||||
metadata: entry.metadata,
|
||||
sizeBytes: entry.bytes.byteLength,
|
||||
createdAt: entry.createdAt,
|
||||
expiresAt: entry.expiresAt,
|
||||
};
|
||||
},
|
||||
async deleteExpired() {
|
||||
const expired: PluginBlobEntryInfo<TMetadata>[] = [];
|
||||
for (const [key, entry] of entries) {
|
||||
if (entry.expiresAt === undefined || entry.expiresAt > Date.now()) {
|
||||
continue;
|
||||
}
|
||||
entries.delete(key);
|
||||
expired.push({
|
||||
key,
|
||||
metadata: entry.metadata,
|
||||
sizeBytes: entry.bytes.byteLength,
|
||||
createdAt: entry.createdAt,
|
||||
expiresAt: entry.expiresAt,
|
||||
});
|
||||
}
|
||||
return expired;
|
||||
},
|
||||
async clear() {
|
||||
entries.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
browser: {
|
||||
|
||||
@@ -97,23 +97,24 @@ export function createDiffsHttpHandler(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
const artifact = await params.store.getArtifact(id, token);
|
||||
if (!artifact) {
|
||||
recordRemoteFailure(viewerFailureLimiter, access);
|
||||
respondText(res, 404, "Diff not found or expired");
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await params.store.readHtml(id);
|
||||
// Authorization and payload read share one SQLite row snapshot. Keeping
|
||||
// them together prevents a replacement between token check and response.
|
||||
const viewer = await params.store.readAuthorizedViewer(id, token);
|
||||
if (!viewer) {
|
||||
recordRemoteFailure(viewerFailureLimiter, access);
|
||||
respondText(res, 404, "Diff not found or expired");
|
||||
return true;
|
||||
}
|
||||
resetRemoteFailures(viewerFailureLimiter, access);
|
||||
res.statusCode = 200;
|
||||
setSharedHeaders(res, "text/html; charset=utf-8");
|
||||
res.setHeader("content-security-policy", VIEWER_CONTENT_SECURITY_POLICY);
|
||||
res.setHeader("content-length", String(viewer.html.byteLength));
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
res.end(html);
|
||||
res.end(Buffer.from(viewer.html));
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("diffs plugin language-pack discovery", () => {
|
||||
'{"id":"diffs-language-pack"}\n',
|
||||
);
|
||||
const config = { plugins: {} } as OpenClawConfig;
|
||||
const openBlobStore = vi.fn(() => createBlobStoreStub());
|
||||
let registeredToolFactory:
|
||||
| ((
|
||||
ctx: OpenClawPluginToolContext,
|
||||
@@ -43,13 +44,23 @@ describe("diffs plugin language-pack discovery", () => {
|
||||
const api = createTestPluginApi({
|
||||
rootDir: diffsRoot,
|
||||
config,
|
||||
runtime: { config: { current: () => config } } as never,
|
||||
runtime: {
|
||||
config: { current: () => config },
|
||||
state: { openBlobStore },
|
||||
} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
});
|
||||
|
||||
registerDiffsPlugin(api);
|
||||
expect(openBlobStore).toHaveBeenCalledWith({
|
||||
namespace: "diff-artifacts",
|
||||
maxEntries: 2_048,
|
||||
maxBytesPerEntry: 32 * 1024 * 1024,
|
||||
maxBytesPerNamespace: 256 * 1024 * 1024,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
const context = {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
@@ -76,3 +87,15 @@ describe("diffs plugin language-pack discovery", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function createBlobStoreStub() {
|
||||
return {
|
||||
register: vi.fn(),
|
||||
registerIfAbsent: vi.fn(),
|
||||
lookup: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteExpired: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,12 +16,24 @@ import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DIFFS_AGENT_GUIDANCE } from "./prompt-guidance.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffsTool } from "./tool.js";
|
||||
import type { DiffArtifactBlobMetadata } from "./types.js";
|
||||
|
||||
const DIFFS_LANGUAGE_PACK_PLUGIN_ID = "diffs-language-pack";
|
||||
const DIFF_ARTIFACT_NAMESPACE = "diff-artifacts";
|
||||
const DIFF_ARTIFACT_MAX_ENTRIES = 2_048;
|
||||
const DIFF_ARTIFACT_MAX_BYTES_PER_ENTRY = 32 * 1024 * 1024;
|
||||
const DIFF_ARTIFACT_MAX_BYTES_PER_NAMESPACE = 256 * 1024 * 1024;
|
||||
|
||||
export function registerDiffsPlugin(api: OpenClawPluginApi): void {
|
||||
const store = new DiffArtifactStore({
|
||||
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
|
||||
blobStore: api.runtime.state.openBlobStore<DiffArtifactBlobMetadata>({
|
||||
namespace: DIFF_ARTIFACT_NAMESPACE,
|
||||
maxEntries: DIFF_ARTIFACT_MAX_ENTRIES,
|
||||
maxBytesPerEntry: DIFF_ARTIFACT_MAX_BYTES_PER_ENTRY,
|
||||
maxBytesPerNamespace: DIFF_ARTIFACT_MAX_BYTES_PER_NAMESPACE,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
logger: api.logger,
|
||||
});
|
||||
const resolveCurrentPluginConfig = () =>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import type { PluginBlobStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { createMockServerResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness, ensureCuratedViewerRuntimeForTests } from "./test-helpers.js";
|
||||
import type { DiffArtifactBlobMetadata } from "./types.js";
|
||||
|
||||
beforeAll(async () => {
|
||||
await ensureCuratedViewerRuntimeForTests();
|
||||
@@ -15,12 +17,16 @@ beforeAll(async () => {
|
||||
describe("DiffArtifactStore", () => {
|
||||
let rootDir: string;
|
||||
let store: DiffArtifactStore;
|
||||
let blobStore: PluginBlobStore<DiffArtifactBlobMetadata>;
|
||||
let reopenStore: Awaited<ReturnType<typeof createDiffStoreHarness>>["reopen"];
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
({
|
||||
rootDir,
|
||||
store,
|
||||
blobStore,
|
||||
reopen: reopenStore,
|
||||
cleanup: cleanupRootDir,
|
||||
} = await createDiffStoreHarness("openclaw-diffs-store-"));
|
||||
});
|
||||
@@ -30,9 +36,10 @@ describe("DiffArtifactStore", () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("creates and retrieves an artifact", async () => {
|
||||
it("stores compressed viewer bytes and retrieves them with one authorized lookup", async () => {
|
||||
const lookup = vi.spyOn(blobStore, "lookup");
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
html: "<html>demo é 🦀</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
@@ -43,18 +50,29 @@ describe("DiffArtifactStore", () => {
|
||||
agentAccountId: "default",
|
||||
},
|
||||
});
|
||||
const stored = await blobStore.lookup(artifact.id);
|
||||
expect(stored?.metadata).toMatchObject({
|
||||
version: 1,
|
||||
kind: "viewer",
|
||||
encoding: "gzip",
|
||||
decodedBytes: Buffer.byteLength("<html>demo é 🦀</html>"),
|
||||
});
|
||||
expect(JSON.stringify(stored?.metadata)).not.toContain(artifact.token);
|
||||
await expect(fs.stat(rootDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const loaded = await store.getArtifact(artifact.id, artifact.token);
|
||||
expect(loaded?.id).toBe(artifact.id);
|
||||
expect(loaded?.context).toEqual({
|
||||
lookup.mockClear();
|
||||
const loaded = await store.readAuthorizedViewer(artifact.id, artifact.token);
|
||||
expect(loaded?.artifact.id).toBe(artifact.id);
|
||||
expect(loaded?.artifact.context).toEqual({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
});
|
||||
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
|
||||
await expect(store.getArtifact(artifact.id, "0".repeat(48))).resolves.toBeNull();
|
||||
await expect(store.getArtifact(artifact.id, "short")).resolves.toBeNull();
|
||||
expect(Buffer.from(loaded!.html).toString("utf8")).toBe("<html>demo é 🦀</html>");
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
await expect(store.readAuthorizedViewer(artifact.id, "0".repeat(48))).resolves.toBeNull();
|
||||
await expect(store.readAuthorizedViewer(artifact.id, "short")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("caps artifact expiry instead of throwing near the Date boundary", async () => {
|
||||
@@ -72,6 +90,23 @@ describe("DiffArtifactStore", () => {
|
||||
expect(artifact.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("serves viewer artifacts after reopening the shared SQLite store", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>persisted</html>",
|
||||
title: "Persisted",
|
||||
inputKind: "patch",
|
||||
fileCount: 1,
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
({ store, blobStore } = reopenStore());
|
||||
|
||||
const loaded = await store.readAuthorizedViewer(artifact.id, artifact.token);
|
||||
expect(Buffer.from(loaded!.html).toString("utf8")).toBe("<html>persisted</html>");
|
||||
});
|
||||
|
||||
it("expires artifacts after the ttl", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
@@ -86,54 +121,12 @@ describe("DiffArtifactStore", () => {
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 2_000));
|
||||
const loaded = await store.getArtifact(artifact.id, artifact.token);
|
||||
const loaded = await store.readAuthorizedViewer(artifact.id, artifact.token);
|
||||
expect(loaded).toBeNull();
|
||||
await expect(blobStore.deleteExpired()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("updates the stored file path", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const filePath = store.allocateFilePath(artifact.id);
|
||||
const updated = await store.updateFilePath(artifact.id, filePath);
|
||||
expect(updated.filePath).toBe(filePath);
|
||||
expect(updated.imagePath).toBe(filePath);
|
||||
});
|
||||
|
||||
it("rejects file paths that escape the store root", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
await expect(store.updateFilePath(artifact.id, "../outside.png")).rejects.toThrow(
|
||||
"escapes store root",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects tampered html metadata paths outside the store root", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
const metaPath = path.join(rootDir, artifact.id, "meta.json");
|
||||
const rawMeta = await fs.readFile(metaPath, "utf8");
|
||||
const meta = JSON.parse(rawMeta) as { htmlPath: string };
|
||||
meta.htmlPath = "../outside.html";
|
||||
await fs.writeFile(metaPath, JSON.stringify(meta), "utf8");
|
||||
|
||||
await expect(store.readHtml(artifact.id)).rejects.toThrow("escapes store root");
|
||||
});
|
||||
|
||||
it("creates standalone file artifacts with managed metadata", async () => {
|
||||
it("creates standalone file artifacts with SQLite metadata and derived temp paths", async () => {
|
||||
const standalone = await store.createStandaloneFileArtifact({
|
||||
context: {
|
||||
agentId: "main",
|
||||
@@ -147,6 +140,12 @@ describe("DiffArtifactStore", () => {
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
});
|
||||
await expect(blobStore.lookup(standalone.id)).resolves.toMatchObject({
|
||||
key: standalone.id,
|
||||
sizeBytes: 0,
|
||||
metadata: { version: 1, kind: "rendered_file", format: "png" },
|
||||
});
|
||||
await store.completeFileArtifact(standalone.id);
|
||||
});
|
||||
|
||||
it("caps standalone file expiry instead of throwing near the Date boundary", async () => {
|
||||
@@ -168,6 +167,7 @@ describe("DiffArtifactStore", () => {
|
||||
ttlMs: 1_000,
|
||||
});
|
||||
await fs.writeFile(standalone.filePath, Buffer.from("png"));
|
||||
await store.completeFileArtifact(standalone.id);
|
||||
|
||||
vi.setSystemTime(new Date(now.getTime() + 2_000));
|
||||
await store.cleanupExpired();
|
||||
@@ -180,36 +180,95 @@ describe("DiffArtifactStore", () => {
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
});
|
||||
|
||||
it("supports image path aliases for backward compatibility", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const imagePath = store.allocateImagePath(artifact.id, "pdf");
|
||||
expect(imagePath).toMatch(/preview\.pdf$/);
|
||||
const standalone = await store.createStandaloneFileArtifact();
|
||||
expect(standalone.filePath).toMatch(/preview\.png$/);
|
||||
|
||||
const updated = await store.updateImagePath(artifact.id, imagePath);
|
||||
expect(updated.filePath).toBe(imagePath);
|
||||
expect(updated.imagePath).toBe(imagePath);
|
||||
it("allocates PDF file paths when format is pdf", async () => {
|
||||
const standalonePdf = await store.createStandaloneFileArtifact({ format: "pdf" });
|
||||
expect(standalonePdf.filePath).toMatch(/preview\.pdf$/);
|
||||
await store.completeFileArtifact(standalonePdf.id);
|
||||
});
|
||||
|
||||
it("allocates PDF file paths when format is pdf", async () => {
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
it("drops an artifact row and temp directory after render failure", async () => {
|
||||
const standalone = await store.createStandaloneFileArtifact();
|
||||
await fs.writeFile(standalone.filePath, "partial");
|
||||
|
||||
await store.deleteFileArtifact(standalone.id);
|
||||
|
||||
await expect(blobStore.lookup(standalone.id)).resolves.toBeUndefined();
|
||||
await expect(fs.stat(path.dirname(standalone.filePath))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes only expired file rows and leaves live materializations", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-27T16:00:00Z"));
|
||||
const expired = await store.createStandaloneFileArtifact({ ttlMs: 1_000 });
|
||||
const live = await store.createStandaloneFileArtifact({ ttlMs: 60_000 });
|
||||
await fs.writeFile(expired.filePath, "expired");
|
||||
await fs.writeFile(live.filePath, "live");
|
||||
await store.completeFileArtifact(expired.id);
|
||||
await store.completeFileArtifact(live.id);
|
||||
|
||||
vi.setSystemTime(new Date("2026-02-27T16:00:02Z"));
|
||||
await store.cleanupExpired();
|
||||
|
||||
await expect(fs.stat(path.dirname(expired.filePath))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.stat(live.filePath)).resolves.toMatchObject({ size: 4 });
|
||||
});
|
||||
|
||||
it("keeps expired file metadata claimable across later blob writes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-27T16:00:00Z"));
|
||||
const expired = await store.createStandaloneFileArtifact({ ttlMs: 1_000 });
|
||||
await fs.writeFile(expired.filePath, "expired");
|
||||
await store.completeFileArtifact(expired.id);
|
||||
|
||||
vi.setSystemTime(new Date("2026-02-27T16:00:02Z"));
|
||||
await blobStore.register(
|
||||
"later-write",
|
||||
new Uint8Array(),
|
||||
{ version: 1, kind: "rendered_file", format: "png" },
|
||||
{ ttlMs: 60_000 },
|
||||
);
|
||||
await store.cleanupExpired();
|
||||
|
||||
await expect(fs.stat(path.dirname(expired.filePath))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("cleans expired rows and retries a quota-limited registration", async () => {
|
||||
const registerIfAbsent = blobStore.registerIfAbsent.bind(blobStore);
|
||||
const registerSpy = vi
|
||||
.spyOn(blobStore, "registerIfAbsent")
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("physical quota reached"), {
|
||||
code: "PLUGIN_BLOB_LIMIT_EXCEEDED",
|
||||
}),
|
||||
)
|
||||
.mockImplementation(registerIfAbsent);
|
||||
const cleanupSpy = vi.spyOn(store, "cleanupExpired").mockResolvedValue();
|
||||
|
||||
await store.createArtifact({
|
||||
html: "<html>retry</html>",
|
||||
title: "Retry",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
|
||||
const artifactPdf = store.allocateFilePath(artifact.id, "pdf");
|
||||
const standalonePdf = await store.createStandaloneFileArtifact({ format: "pdf" });
|
||||
expect(artifactPdf).toMatch(/preview\.pdf$/);
|
||||
expect(standalonePdf.filePath).toMatch(/preview\.pdf$/);
|
||||
expect(registerSpy).toHaveBeenCalledTimes(2);
|
||||
expect(cleanupSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes only old rowless temp directories without reading legacy metadata", async () => {
|
||||
const oldDir = path.join(rootDir, "a".repeat(20));
|
||||
const recentDir = path.join(rootDir, "b".repeat(20));
|
||||
await fs.mkdir(oldDir, { recursive: true });
|
||||
await fs.mkdir(recentDir, { recursive: true });
|
||||
const oldTime = new Date(Date.now() - 25 * 60 * 60 * 1_000);
|
||||
await fs.utimes(oldDir, oldTime, oldTime);
|
||||
|
||||
await store.cleanupExpired();
|
||||
|
||||
await expect(fs.stat(oldDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(fs.stat(recentDir)).resolves.toMatchObject({});
|
||||
});
|
||||
|
||||
it("throttles cleanup sweeps across repeated artifact creation", async () => {
|
||||
@@ -218,6 +277,7 @@ describe("DiffArtifactStore", () => {
|
||||
vi.setSystemTime(now);
|
||||
store = new DiffArtifactStore({
|
||||
rootDir,
|
||||
blobStore,
|
||||
cleanupIntervalMs: 60_000,
|
||||
});
|
||||
const cleanupSpy = vi.spyOn(store, "cleanupExpired").mockResolvedValue();
|
||||
@@ -280,7 +340,9 @@ describe("createDiffsHttpHandler", () => {
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(Buffer.from(res.body as unknown as Uint8Array).toString("utf8")).toBe(
|
||||
"<html>viewer</html>",
|
||||
);
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
expect(res.getHeader("cache-control")).toBe("no-store, max-age=0");
|
||||
});
|
||||
@@ -418,7 +480,9 @@ describe("createDiffsHttpHandler", () => {
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(Buffer.from(res.body as unknown as Uint8Array).toString("utf8")).toBe(
|
||||
"<html>viewer</html>",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
+300
-256
@@ -2,17 +2,34 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { gunzip, gzip } from "node:zlib";
|
||||
import { MAX_DATE_TIMESTAMP_MS, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { root as fsRoot, safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
PluginBlobEntry,
|
||||
PluginBlobEntryInfo,
|
||||
PluginBlobStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { PluginLogger } from "../api.js";
|
||||
import type { DiffArtifactContext, DiffArtifactMeta, DiffOutputFormat } from "./types.js";
|
||||
import {
|
||||
DIFF_ARTIFACT_ID_PATTERN,
|
||||
DIFF_ARTIFACT_TOKEN_PATTERN,
|
||||
type DiffArtifactBlobMetadata,
|
||||
type DiffArtifactContext,
|
||||
type DiffArtifactMeta,
|
||||
type DiffOutputFormat,
|
||||
type DiffRenderedFileArtifactMetadata,
|
||||
type DiffViewerArtifactMetadata,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
||||
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const SWEEP_FALLBACK_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const MAX_DECODED_HTML_BYTES = 64 * 1024 * 1024;
|
||||
const ARTIFACT_ID_ATTEMPTS = 8;
|
||||
const VIEWER_PREFIX = "/plugins/diffs/view";
|
||||
const EMPTY_BLOB = new Uint8Array();
|
||||
|
||||
type CreateArtifactParams = {
|
||||
html: string;
|
||||
@@ -29,27 +46,44 @@ type CreateStandaloneFileArtifactParams = {
|
||||
context?: DiffArtifactContext;
|
||||
};
|
||||
|
||||
type StandaloneFileMeta = {
|
||||
kind: "standalone_file";
|
||||
type DiffStandaloneFileArtifact = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
filePath: string;
|
||||
expiresAt: string;
|
||||
context?: DiffArtifactContext;
|
||||
};
|
||||
|
||||
type ArtifactMetaFileName = "meta.json" | "file-meta.json";
|
||||
type ArtifactRoot = Awaited<ReturnType<typeof fsRoot>>;
|
||||
type DiffAuthorizedViewer = {
|
||||
artifact: DiffArtifactMeta;
|
||||
html: Uint8Array;
|
||||
};
|
||||
|
||||
function isBlobLimitError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "PLUGIN_BLOB_LIMIT_EXCEEDED"
|
||||
);
|
||||
}
|
||||
|
||||
export class DiffArtifactStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly blobStore: PluginBlobStore<DiffArtifactBlobMetadata>;
|
||||
private readonly logger?: PluginLogger;
|
||||
private readonly cleanupIntervalMs: number;
|
||||
private readonly renderingFileIds = new Set<string>();
|
||||
private cleanupInFlight: Promise<void> | null = null;
|
||||
private nextCleanupAt = 0;
|
||||
|
||||
constructor(params: { rootDir: string; logger?: PluginLogger; cleanupIntervalMs?: number }) {
|
||||
constructor(params: {
|
||||
rootDir: string;
|
||||
blobStore: PluginBlobStore<DiffArtifactBlobMetadata>;
|
||||
logger?: PluginLogger;
|
||||
cleanupIntervalMs?: number;
|
||||
}) {
|
||||
this.rootDir = path.resolve(params.rootDir);
|
||||
this.blobStore = params.blobStore;
|
||||
this.logger = params.logger;
|
||||
this.cleanupIntervalMs =
|
||||
params.cleanupIntervalMs === undefined
|
||||
@@ -58,119 +92,119 @@ export class DiffArtifactStore {
|
||||
}
|
||||
|
||||
async createArtifact(params: CreateArtifactParams): Promise<DiffArtifactMeta> {
|
||||
await this.ensureRoot();
|
||||
|
||||
const id = crypto.randomBytes(10).toString("hex");
|
||||
const html = Buffer.from(params.html, "utf8");
|
||||
if (html.byteLength > MAX_DECODED_HTML_BYTES) {
|
||||
throw new Error(`Diff viewer HTML exceeds ${MAX_DECODED_HTML_BYTES} bytes.`);
|
||||
}
|
||||
const compressedHtml = await gzipAsync(html);
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const artifactDir = this.artifactDir(id);
|
||||
const htmlPath = path.join(artifactDir, "viewer.html");
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const createdAtIso = createdAt.toISOString();
|
||||
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
|
||||
const meta: DiffArtifactMeta = {
|
||||
id,
|
||||
token,
|
||||
const metadata: DiffViewerArtifactMetadata = {
|
||||
version: 1,
|
||||
kind: "viewer",
|
||||
encoding: "gzip",
|
||||
tokenHash: hashToken(token),
|
||||
title: params.title,
|
||||
inputKind: params.inputKind,
|
||||
fileCount: params.fileCount,
|
||||
createdAt: createdAtIso,
|
||||
expiresAt,
|
||||
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
|
||||
htmlPath,
|
||||
decodedBytes: html.byteLength,
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
};
|
||||
|
||||
const root = await this.artifactRoot();
|
||||
await root.mkdir(id);
|
||||
await root.write(path.posix.join(id, "viewer.html"), params.html);
|
||||
await this.writeMeta(meta);
|
||||
const entry = await this.registerUnique(compressedHtml, metadata, ttlMs);
|
||||
this.scheduleCleanup();
|
||||
return meta;
|
||||
return viewerEntryToMeta(entry, token);
|
||||
}
|
||||
|
||||
async getArtifact(id: string, token: string): Promise<DiffArtifactMeta | null> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
async readAuthorizedViewer(id: string, token: string): Promise<DiffAuthorizedViewer | null> {
|
||||
if (!DIFF_ARTIFACT_ID_PATTERN.test(id) || !DIFF_ARTIFACT_TOKEN_PATTERN.test(token)) {
|
||||
return null;
|
||||
}
|
||||
if (!safeEqualSecret(token, meta.token)) {
|
||||
const entry = await this.blobStore.lookup(id);
|
||||
if (!entry) {
|
||||
const expired = await this.blobStore.deleteExpiredKey(id);
|
||||
if (expired) {
|
||||
await this.deleteExpiredFile(expired);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isExpired(meta)) {
|
||||
await this.deleteArtifact(id);
|
||||
if (!isViewerMetadata(entry.metadata)) {
|
||||
return null;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
async readHtml(id: string): Promise<string> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
throw new Error(`Diff artifact not found: ${id}`);
|
||||
const tokenHash = hashToken(token);
|
||||
if (!safeEqualSecret(tokenHash, entry.metadata.tokenHash)) {
|
||||
return null;
|
||||
}
|
||||
const htmlPath = this.normalizeStoredPath(meta.htmlPath, "htmlPath");
|
||||
return await (await this.artifactRoot()).readText(this.relativeStoredPath(htmlPath));
|
||||
}
|
||||
|
||||
async updateFilePath(id: string, filePath: string): Promise<DiffArtifactMeta> {
|
||||
const meta = await this.readMeta(id);
|
||||
if (!meta) {
|
||||
throw new Error(`Diff artifact not found: ${id}`);
|
||||
const html = await gunzipAsync(entry.bytes, MAX_DECODED_HTML_BYTES);
|
||||
if (html.byteLength !== entry.metadata.decodedBytes) {
|
||||
throw new Error(`Diff artifact ${id} decoded size does not match its metadata.`);
|
||||
}
|
||||
const normalizedFilePath = this.normalizeStoredPath(filePath, "filePath");
|
||||
const next: DiffArtifactMeta = {
|
||||
...meta,
|
||||
filePath: normalizedFilePath,
|
||||
imagePath: normalizedFilePath,
|
||||
return {
|
||||
artifact: viewerEntryToMeta(entry, token),
|
||||
html,
|
||||
};
|
||||
await this.writeMeta(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
|
||||
return this.updateFilePath(id, imagePath);
|
||||
}
|
||||
|
||||
allocateFilePath(id: string, format: DiffOutputFormat = "png"): string {
|
||||
return path.join(this.artifactDir(id), `preview.${format}`);
|
||||
}
|
||||
|
||||
async createStandaloneFileArtifact(
|
||||
params: CreateStandaloneFileArtifactParams = {},
|
||||
): Promise<{ id: string; filePath: string; expiresAt: string; context?: DiffArtifactContext }> {
|
||||
await this.ensureRoot();
|
||||
|
||||
const id = crypto.randomBytes(10).toString("hex");
|
||||
const artifactDir = this.artifactDir(id);
|
||||
): Promise<DiffStandaloneFileArtifact> {
|
||||
const format = params.format ?? "png";
|
||||
const filePath = path.join(artifactDir, `preview.${format}`);
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const createdAtIso = createdAt.toISOString();
|
||||
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
|
||||
const meta: StandaloneFileMeta = {
|
||||
kind: "standalone_file",
|
||||
id,
|
||||
createdAt: createdAtIso,
|
||||
expiresAt,
|
||||
filePath: this.normalizeStoredPath(filePath, "filePath"),
|
||||
const metadata: DiffRenderedFileArtifactMetadata = {
|
||||
version: 1,
|
||||
kind: "rendered_file",
|
||||
format,
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
};
|
||||
|
||||
await (await this.artifactRoot()).mkdir(id);
|
||||
await this.writeStandaloneMeta(meta);
|
||||
this.scheduleCleanup();
|
||||
return {
|
||||
id,
|
||||
filePath: meta.filePath,
|
||||
expiresAt: meta.expiresAt,
|
||||
...(meta.context ? { context: meta.context } : {}),
|
||||
};
|
||||
for (let attempt = 0; attempt < ARTIFACT_ID_ATTEMPTS; attempt += 1) {
|
||||
const id = crypto.randomBytes(10).toString("hex");
|
||||
if (!(await this.registerIfAbsentWithCleanup(id, EMPTY_BLOB, metadata, ttlMs))) {
|
||||
continue;
|
||||
}
|
||||
const artifactDir = this.artifactDir(id);
|
||||
try {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
await fs.mkdir(artifactDir);
|
||||
const entry = await this.blobStore.lookup(id);
|
||||
if (!entry || !isRenderedFileMetadata(entry.metadata)) {
|
||||
throw new Error(`Diff file artifact expired before materialization: ${id}`);
|
||||
}
|
||||
this.renderingFileIds.add(id);
|
||||
this.scheduleCleanup();
|
||||
return {
|
||||
id,
|
||||
filePath: path.join(artifactDir, `preview.${format}`),
|
||||
expiresAt: resolveEntryExpiresAt(entry),
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
await this.blobStore.delete(id).catch(() => false);
|
||||
await fs.rm(artifactDir, { recursive: true, force: true }).catch(() => {});
|
||||
if (isFileExists(error)) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to allocate a unique diff file artifact id.");
|
||||
}
|
||||
|
||||
allocateImagePath(id: string, format: DiffOutputFormat = "png"): string {
|
||||
return this.allocateFilePath(id, format);
|
||||
async completeFileArtifact(id: string): Promise<void> {
|
||||
try {
|
||||
const entry = await this.blobStore.lookup(id);
|
||||
if (!entry || !isRenderedFileMetadata(entry.metadata)) {
|
||||
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
|
||||
throw new Error(`Diff file artifact expired during rendering: ${id}`);
|
||||
}
|
||||
} finally {
|
||||
this.renderingFileIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFileArtifact(id: string): Promise<void> {
|
||||
this.renderingFileIds.delete(id);
|
||||
await this.blobStore.delete(id).catch(() => false);
|
||||
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
scheduleCleanup(): void {
|
||||
@@ -178,45 +212,83 @@ export class DiffArtifactStore {
|
||||
}
|
||||
|
||||
async cleanupExpired(): Promise<void> {
|
||||
const root = await this.artifactRoot();
|
||||
const entries = await root.list("", { withFileTypes: true }).catch(() => []);
|
||||
const now = Date.now();
|
||||
const expired = await this.blobStore.deleteExpired();
|
||||
await Promise.all(expired.map(async (entry) => await this.deleteExpiredFile(entry)));
|
||||
|
||||
const entries = await fs
|
||||
.readdir(this.rootDir, { withFileTypes: true })
|
||||
.catch((error: unknown) => {
|
||||
if (isFileNotFound(error)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory)
|
||||
.filter((entry) => entry.isDirectory() && DIFF_ARTIFACT_ID_PATTERN.test(entry.name))
|
||||
.map(async (entry) => {
|
||||
const id = entry.name;
|
||||
const meta = await this.readMeta(id);
|
||||
if (meta) {
|
||||
if (isExpired(meta)) {
|
||||
await this.deleteArtifact(id);
|
||||
}
|
||||
if (this.renderingFileIds.has(entry.name) || (await this.blobStore.lookup(entry.name))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const standaloneMeta = await this.readStandaloneMeta(id);
|
||||
if (standaloneMeta) {
|
||||
if (isExpired(standaloneMeta)) {
|
||||
await this.deleteArtifact(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (now - entry.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
|
||||
await this.deleteArtifact(id);
|
||||
const artifactDir = this.artifactDir(entry.name);
|
||||
const stats = await fs.stat(artifactDir).catch(() => null);
|
||||
if (stats && now - stats.mtimeMs > SWEEP_FALLBACK_AGE_MS) {
|
||||
await fs.rm(artifactDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureRoot(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
private async registerUnique(
|
||||
bytes: Uint8Array,
|
||||
metadata: DiffArtifactBlobMetadata,
|
||||
ttlMs: number,
|
||||
): Promise<PluginBlobEntry<DiffArtifactBlobMetadata>> {
|
||||
for (let attempt = 0; attempt < ARTIFACT_ID_ATTEMPTS; attempt += 1) {
|
||||
const id = crypto.randomBytes(10).toString("hex");
|
||||
if (!(await this.registerIfAbsentWithCleanup(id, bytes, metadata, ttlMs))) {
|
||||
continue;
|
||||
}
|
||||
const entry = await this.blobStore.lookup(id);
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to allocate a unique diff artifact id.");
|
||||
}
|
||||
|
||||
private async artifactRoot(): Promise<ArtifactRoot> {
|
||||
await this.ensureRoot();
|
||||
return await fsRoot(this.rootDir);
|
||||
private async registerIfAbsentWithCleanup(
|
||||
id: string,
|
||||
bytes: Uint8Array,
|
||||
metadata: DiffArtifactBlobMetadata,
|
||||
ttlMs: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await this.blobStore.registerIfAbsent(id, bytes, metadata, { ttlMs });
|
||||
} catch (error) {
|
||||
if (!isBlobLimitError(error)) {
|
||||
throw error;
|
||||
}
|
||||
// Expired rows retain cleanup metadata and count toward physical fuses.
|
||||
// Claim their cleanup before retrying a write that reached the quota.
|
||||
await this.cleanupExpired();
|
||||
return await this.blobStore.registerIfAbsent(id, bytes, metadata, { ttlMs });
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteExpiredFile(
|
||||
entry: PluginBlobEntryInfo<DiffArtifactBlobMetadata>,
|
||||
): Promise<void> {
|
||||
if (!isRenderedFileMetadata(entry.metadata) || this.renderingFileIds.has(entry.key)) {
|
||||
return;
|
||||
}
|
||||
// A current row wins over the expired snapshot. This prevents cleanup from
|
||||
// deleting a materialization if an id was replaced after the TTL transaction.
|
||||
if (await this.blobStore.lookup(entry.key)) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(this.artifactDir(entry.key), { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
private maybeCleanupExpired(): void {
|
||||
@@ -241,159 +313,131 @@ export class DiffArtifactStore {
|
||||
}
|
||||
|
||||
private artifactDir(id: string): string {
|
||||
return this.resolveWithinRoot(id);
|
||||
}
|
||||
|
||||
private async writeMeta(meta: DiffArtifactMeta): Promise<void> {
|
||||
await this.writeJsonMeta(meta.id, "meta.json", meta);
|
||||
}
|
||||
|
||||
private async readMeta(id: string): Promise<DiffArtifactMeta | null> {
|
||||
const parsed = await this.readJsonMeta(id, "meta.json", "diff artifact");
|
||||
if (!parsed) {
|
||||
return null;
|
||||
if (!DIFF_ARTIFACT_ID_PATTERN.test(id)) {
|
||||
throw new Error(`Invalid diff artifact id: ${id}`);
|
||||
}
|
||||
return parsed as DiffArtifactMeta;
|
||||
}
|
||||
|
||||
private async writeStandaloneMeta(meta: StandaloneFileMeta): Promise<void> {
|
||||
await this.writeJsonMeta(meta.id, "file-meta.json", meta);
|
||||
}
|
||||
|
||||
private async readStandaloneMeta(id: string): Promise<StandaloneFileMeta | null> {
|
||||
const parsed = await this.readJsonMeta(id, "file-meta.json", "standalone diff");
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const value = parsed as Partial<StandaloneFileMeta>;
|
||||
if (
|
||||
value.kind !== "standalone_file" ||
|
||||
typeof value.id !== "string" ||
|
||||
typeof value.createdAt !== "string" ||
|
||||
typeof value.expiresAt !== "string" ||
|
||||
typeof value.filePath !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: value.kind,
|
||||
id: value.id,
|
||||
createdAt: value.createdAt,
|
||||
expiresAt: value.expiresAt,
|
||||
filePath: this.normalizeStoredPath(value.filePath, "filePath"),
|
||||
...(value.context ? { context: normalizeArtifactContext(value.context) } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger?.warn(`Failed to normalize standalone diff metadata for ${id}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeJsonMeta(
|
||||
id: string,
|
||||
fileName: ArtifactMetaFileName,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
await (await this.artifactRoot()).writeJson(path.posix.join(id, fileName), data, { space: 2 });
|
||||
}
|
||||
|
||||
private async readJsonMeta(
|
||||
id: string,
|
||||
fileName: ArtifactMetaFileName,
|
||||
context: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
const raw = await (await this.artifactRoot()).readText(path.posix.join(id, fileName));
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch (error) {
|
||||
if (isFileNotFound(error)) {
|
||||
return null;
|
||||
}
|
||||
this.logger?.warn(`Failed to read ${context} metadata for ${id}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteArtifact(id: string): Promise<void> {
|
||||
await fs.rm(this.artifactDir(id), { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
private resolveWithinRoot(...parts: string[]): string {
|
||||
const candidate = path.resolve(this.rootDir, ...parts);
|
||||
this.assertWithinRoot(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private normalizeStoredPath(rawPath: string, label: string): string {
|
||||
const candidate = path.isAbsolute(rawPath)
|
||||
? path.resolve(rawPath)
|
||||
: path.resolve(this.rootDir, rawPath);
|
||||
this.assertWithinRoot(candidate, label);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private relativeStoredPath(storedPath: string): string {
|
||||
const relativePath = path.relative(this.rootDir, this.normalizeStoredPath(storedPath, "path"));
|
||||
return relativePath.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
private assertWithinRoot(candidate: string, label = "path"): void {
|
||||
const relative = path.relative(this.rootDir, candidate);
|
||||
if (
|
||||
relative === "" ||
|
||||
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Diff artifact ${label} escapes store root: ${candidate}`);
|
||||
return path.join(this.rootDir, id);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTtlMs(value?: number): number {
|
||||
if (!Number.isFinite(value) || value === undefined) {
|
||||
return DEFAULT_TTL_MS;
|
||||
function viewerEntryToMeta(
|
||||
entry: PluginBlobEntry<DiffArtifactBlobMetadata>,
|
||||
token: string,
|
||||
): DiffArtifactMeta {
|
||||
if (!isViewerMetadata(entry.metadata)) {
|
||||
throw new Error(`Diff artifact ${entry.key} is not a viewer.`);
|
||||
}
|
||||
const rounded = Math.floor(value);
|
||||
if (rounded <= 0) {
|
||||
return DEFAULT_TTL_MS;
|
||||
}
|
||||
return Math.min(rounded, MAX_TTL_MS);
|
||||
return {
|
||||
id: entry.key,
|
||||
token,
|
||||
createdAt: timestampMsToIsoString(entry.createdAt) ?? "1970-01-01T00:00:00.000Z",
|
||||
expiresAt: resolveEntryExpiresAt(entry),
|
||||
title: entry.metadata.title,
|
||||
inputKind: entry.metadata.inputKind,
|
||||
fileCount: entry.metadata.fileCount,
|
||||
viewerPath: `${VIEWER_PREFIX}/${entry.key}/${token}`,
|
||||
...(entry.metadata.context ? { context: entry.metadata.context } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExpiresAtIso(createdAtMs: number, ttlMs: number): string {
|
||||
function resolveEntryExpiresAt(entry: { expiresAt?: number }): string {
|
||||
return (
|
||||
timestampMsToIsoString(createdAtMs + ttlMs) ??
|
||||
timestampMsToIsoString(entry.expiresAt ?? MAX_DATE_TIMESTAMP_MS) ??
|
||||
timestampMsToIsoString(MAX_DATE_TIMESTAMP_MS) ??
|
||||
"1970-01-01T00:00:00.000Z"
|
||||
);
|
||||
}
|
||||
|
||||
function isExpired(meta: { expiresAt: string }): boolean {
|
||||
const expiresAt = Date.parse(meta.expiresAt);
|
||||
if (!Number.isFinite(expiresAt)) {
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeTtlMs(value?: number): number {
|
||||
const rounded = value === undefined || !Number.isFinite(value) ? 0 : Math.floor(value);
|
||||
const requestedTtlMs = rounded > 0 ? rounded : DEFAULT_TTL_MS;
|
||||
const remainingDateRangeMs = Math.floor(MAX_DATE_TIMESTAMP_MS - Date.now());
|
||||
return Math.min(requestedTtlMs, MAX_TTL_MS, Math.max(1, remainingDateRangeMs));
|
||||
}
|
||||
|
||||
function isViewerMetadata(value: unknown): value is DiffViewerArtifactMetadata {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const metadata = value as Partial<DiffViewerArtifactMetadata>;
|
||||
return (
|
||||
metadata.version === 1 &&
|
||||
metadata.kind === "viewer" &&
|
||||
metadata.encoding === "gzip" &&
|
||||
typeof metadata.tokenHash === "string" &&
|
||||
/^[0-9a-f]{64}$/u.test(metadata.tokenHash) &&
|
||||
typeof metadata.title === "string" &&
|
||||
(metadata.inputKind === "before_after" || metadata.inputKind === "patch") &&
|
||||
Number.isSafeInteger(metadata.fileCount) &&
|
||||
typeof metadata.fileCount === "number" &&
|
||||
metadata.fileCount >= 0 &&
|
||||
Number.isSafeInteger(metadata.decodedBytes) &&
|
||||
typeof metadata.decodedBytes === "number" &&
|
||||
metadata.decodedBytes >= 0 &&
|
||||
metadata.decodedBytes <= MAX_DECODED_HTML_BYTES &&
|
||||
isArtifactContext(metadata.context)
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderedFileMetadata(value: unknown): value is DiffRenderedFileArtifactMetadata {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const metadata = value as Partial<DiffRenderedFileArtifactMetadata>;
|
||||
return (
|
||||
metadata.version === 1 &&
|
||||
metadata.kind === "rendered_file" &&
|
||||
(metadata.format === "png" || metadata.format === "pdf") &&
|
||||
isArtifactContext(metadata.context)
|
||||
);
|
||||
}
|
||||
|
||||
function isArtifactContext(value: unknown): value is DiffArtifactContext | undefined {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() >= expiresAt;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const context = value as Record<string, unknown>;
|
||||
const allowed = new Set(["agentId", "sessionId", "messageChannel", "agentAccountId"]);
|
||||
return Object.entries(context).every(
|
||||
([key, entry]) => allowed.has(key) && (entry === undefined || typeof entry === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
async function gzipAsync(input: Uint8Array): Promise<Uint8Array> {
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
gzip(input, (error, result) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function gunzipAsync(input: Uint8Array, maxOutputLength: number): Promise<Uint8Array> {
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
gunzip(input, { maxOutputLength }, (error, result) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isFileExists(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
||||
}
|
||||
|
||||
function isFileNotFound(error: unknown): boolean {
|
||||
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
||||
return code === "ENOENT" || code === "not-found";
|
||||
}
|
||||
|
||||
function normalizeArtifactContext(value: unknown): DiffArtifactContext | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = value as Record<string, unknown>;
|
||||
const context = {
|
||||
agentId: normalizeOptionalString(raw.agentId),
|
||||
sessionId: normalizeOptionalString(raw.sessionId),
|
||||
messageChannel: normalizeOptionalString(raw.messageChannel),
|
||||
agentAccountId: normalizeOptionalString(raw.agentAccountId),
|
||||
};
|
||||
|
||||
return Object.values(context).some((entry) => entry !== undefined) ? context : undefined;
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import type { PluginBlobStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginBlobStoreForTests,
|
||||
resetPluginBlobStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../api.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import type { DiffArtifactBlobMetadata } from "./types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -51,12 +57,47 @@ export async function createTempDiffRoot(prefix: string): Promise<{
|
||||
export async function createDiffStoreHarness(prefix: string): Promise<{
|
||||
rootDir: string;
|
||||
store: DiffArtifactStore;
|
||||
blobStore: PluginBlobStore<DiffArtifactBlobMetadata>;
|
||||
reopen: () => {
|
||||
store: DiffArtifactStore;
|
||||
blobStore: PluginBlobStore<DiffArtifactBlobMetadata>;
|
||||
};
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const { rootDir, cleanup } = await createTempDiffRoot(prefix);
|
||||
const { rootDir: harnessRoot, cleanup } = await createTempDiffRoot(prefix);
|
||||
const rootDir = path.join(harnessRoot, "files");
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: path.join(harnessRoot, "state"),
|
||||
};
|
||||
const openBlobStore = () =>
|
||||
createPluginBlobStoreForTests<DiffArtifactBlobMetadata>(
|
||||
"diffs",
|
||||
{
|
||||
namespace: "diff-artifacts",
|
||||
maxEntries: 2_048,
|
||||
maxBytesPerEntry: 32 * 1024 * 1024,
|
||||
maxBytesPerNamespace: 256 * 1024 * 1024,
|
||||
overflowPolicy: "reject-new",
|
||||
},
|
||||
env,
|
||||
);
|
||||
const blobStore = openBlobStore();
|
||||
return {
|
||||
rootDir,
|
||||
store: new DiffArtifactStore({ rootDir }),
|
||||
cleanup,
|
||||
store: new DiffArtifactStore({ rootDir, blobStore }),
|
||||
blobStore,
|
||||
reopen: () => {
|
||||
resetPluginBlobStoreForTests();
|
||||
const reopenedBlobStore = openBlobStore();
|
||||
return {
|
||||
store: new DiffArtifactStore({ rootDir, blobStore: reopenedBlobStore }),
|
||||
blobStore: reopenedBlobStore,
|
||||
};
|
||||
},
|
||||
cleanup: async () => {
|
||||
resetPluginBlobStoreForTests();
|
||||
await cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("diffs tool", () => {
|
||||
},
|
||||
});
|
||||
expect(screenshotHtml).not.toHaveBeenCalled();
|
||||
await expect(fs.readdir(rootDir)).resolves.toEqual([]);
|
||||
await expect(fs.stat(rootDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("uses configured viewerBaseUrl when tool input omits baseUrl", async () => {
|
||||
@@ -336,6 +336,7 @@ describe("diffs tool", () => {
|
||||
expect(result?.content).toHaveLength(1);
|
||||
expect(readTextContent(result, 0)).toContain("File rendering failed");
|
||||
expect((result.details as Record<string, unknown>).fileError).toBe("browser missing");
|
||||
await expect(fs.readdir(rootDir)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects invalid base URLs as tool input errors", async () => {
|
||||
@@ -449,7 +450,8 @@ describe("diffs tool", () => {
|
||||
|
||||
const viewerPath = String((result.details as Record<string, unknown>).viewerPath);
|
||||
const id = extractViewerArtifactId(viewerPath);
|
||||
const html = await store.readHtml(id);
|
||||
const viewer = await store.readAuthorizedViewer(id, extractViewerArtifactToken(viewerPath));
|
||||
const html = Buffer.from(viewer!.html).toString("utf8");
|
||||
expect(html).toContain('body data-theme="light"');
|
||||
expect(html).toContain("--diffs-font-size: 17px;");
|
||||
expect(html).toContain("JetBrains Mono");
|
||||
@@ -496,7 +498,8 @@ describe("diffs tool", () => {
|
||||
expect((result.details as Record<string, unknown>).fileMaxWidth).toBe(1320);
|
||||
const viewerPath = String((result.details as Record<string, unknown>).viewerPath);
|
||||
const id = extractViewerArtifactId(viewerPath);
|
||||
const html = await store.readHtml(id);
|
||||
const viewer = await store.readAuthorizedViewer(id, extractViewerArtifactToken(viewerPath));
|
||||
const html = Buffer.from(viewer!.html).toString("utf8");
|
||||
expect(html).toContain('body data-theme="dark"');
|
||||
});
|
||||
|
||||
@@ -522,6 +525,34 @@ describe("diffs tool", () => {
|
||||
agentAccountId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores partial tool context for viewer and rendered-file artifacts", async () => {
|
||||
const screenshotter = createPngScreenshotter();
|
||||
const tool = createToolWithScreenshotter(store, screenshotter, DEFAULT_DIFFS_TOOL_DEFAULTS, {
|
||||
agentId: "reviewer",
|
||||
sessionId: "session-partial",
|
||||
});
|
||||
|
||||
const result = await tool.execute?.("tool-context-partial", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
mode: "both",
|
||||
});
|
||||
|
||||
expect((result.details as Record<string, unknown>).context).toEqual({
|
||||
agentId: "reviewer",
|
||||
sessionId: "session-partial",
|
||||
});
|
||||
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
|
||||
|
||||
const viewerPath = String((result.details as Record<string, unknown>).viewerPath);
|
||||
const id = extractViewerArtifactId(viewerPath);
|
||||
const viewer = await store.readAuthorizedViewer(id, extractViewerArtifactToken(viewerPath));
|
||||
expect(viewer?.artifact.context).toEqual({
|
||||
agentId: "reviewer",
|
||||
sessionId: "session-partial",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createApi(pluginConfig?: Record<string, unknown>): OpenClawPluginApi {
|
||||
@@ -645,6 +676,14 @@ function extractViewerArtifactId(viewerPath: string): string {
|
||||
return previousSegment;
|
||||
}
|
||||
|
||||
function extractViewerArtifactToken(viewerPath: string): string {
|
||||
const token = viewerPath.split("/").findLast((segment) => segment.length > 0);
|
||||
if (!token) {
|
||||
throw new Error("expected viewer artifact token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function readParametersProperties(parameters: unknown): Record<string, unknown> {
|
||||
if (isRecord(parameters) && isRecord(parameters.properties)) {
|
||||
return parameters.properties;
|
||||
|
||||
@@ -272,12 +272,12 @@ export function createDiffsTool(params: {
|
||||
const artifactFile = await renderDiffArtifactFile({
|
||||
screenshotter,
|
||||
store: params.store,
|
||||
artifactId: artifact.id,
|
||||
html: requireRenderedHtml(rendered.imageHtml, "image"),
|
||||
theme,
|
||||
image,
|
||||
ttlMs,
|
||||
context: artifactContext,
|
||||
});
|
||||
await params.store.updateFilePath(artifact.id, artifactFile.path);
|
||||
|
||||
return {
|
||||
content: [
|
||||
@@ -381,38 +381,37 @@ function buildFileArtifactMessage(params: {
|
||||
async function renderDiffArtifactFile(params: {
|
||||
screenshotter: DiffScreenshotter;
|
||||
store: DiffArtifactStore;
|
||||
artifactId?: string;
|
||||
html: string;
|
||||
theme: DiffTheme;
|
||||
image: DiffRenderOptions["image"];
|
||||
ttlMs?: number;
|
||||
context?: DiffArtifactContext;
|
||||
}): Promise<{ path: string; bytes: number; artifactId?: string; expiresAt?: string }> {
|
||||
const standaloneArtifact = params.artifactId
|
||||
? undefined
|
||||
: await params.store.createStandaloneFileArtifact({
|
||||
format: params.image.format,
|
||||
ttlMs: params.ttlMs,
|
||||
context: params.context,
|
||||
});
|
||||
const outputPath = params.artifactId
|
||||
? params.store.allocateFilePath(params.artifactId, params.image.format)
|
||||
: standaloneArtifact!.filePath;
|
||||
|
||||
await params.screenshotter.screenshotHtml({
|
||||
html: params.html,
|
||||
outputPath,
|
||||
theme: params.theme,
|
||||
image: params.image,
|
||||
const fileArtifact = await params.store.createStandaloneFileArtifact({
|
||||
format: params.image.format,
|
||||
ttlMs: params.ttlMs,
|
||||
context: params.context,
|
||||
});
|
||||
try {
|
||||
await params.screenshotter.screenshotHtml({
|
||||
html: params.html,
|
||||
outputPath: fileArtifact.filePath,
|
||||
theme: params.theme,
|
||||
image: params.image,
|
||||
});
|
||||
|
||||
const stats = await fs.stat(outputPath);
|
||||
return {
|
||||
path: outputPath,
|
||||
bytes: stats.size,
|
||||
...(standaloneArtifact?.id ? { artifactId: standaloneArtifact.id } : {}),
|
||||
...(standaloneArtifact?.expiresAt ? { expiresAt: standaloneArtifact.expiresAt } : {}),
|
||||
};
|
||||
const stats = await fs.stat(fileArtifact.filePath);
|
||||
await params.store.completeFileArtifact(fileArtifact.id);
|
||||
return {
|
||||
path: fileArtifact.filePath,
|
||||
bytes: stats.size,
|
||||
artifactId: fileArtifact.id,
|
||||
expiresAt: fileArtifact.expiresAt,
|
||||
};
|
||||
} catch (error) {
|
||||
await params.store.deleteFileArtifact(fileArtifact.id);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function buildArtifactContext(
|
||||
@@ -422,16 +421,18 @@ function buildArtifactContext(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const artifactContext = {
|
||||
agentId: normalizeOptionalString(context.agentId),
|
||||
sessionId: normalizeOptionalString(context.sessionId),
|
||||
messageChannel: normalizeOptionalString(context.messageChannel),
|
||||
agentAccountId: normalizeOptionalString(context.agentAccountId),
|
||||
const agentId = normalizeOptionalString(context.agentId);
|
||||
const sessionId = normalizeOptionalString(context.sessionId);
|
||||
const messageChannel = normalizeOptionalString(context.messageChannel);
|
||||
const agentAccountId = normalizeOptionalString(context.agentAccountId);
|
||||
const artifactContext: DiffArtifactContext = {
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(messageChannel ? { messageChannel } : {}),
|
||||
...(agentAccountId ? { agentAccountId } : {}),
|
||||
};
|
||||
|
||||
return Object.values(artifactContext).some((value) => value !== undefined)
|
||||
? artifactContext
|
||||
: undefined;
|
||||
return Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
|
||||
}
|
||||
|
||||
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
|
||||
|
||||
@@ -111,6 +111,29 @@ export type DiffArtifactContext = {
|
||||
agentAccountId?: string;
|
||||
};
|
||||
|
||||
export type DiffViewerArtifactMetadata = {
|
||||
version: 1;
|
||||
kind: "viewer";
|
||||
encoding: "gzip";
|
||||
tokenHash: string;
|
||||
title: string;
|
||||
inputKind: DiffInput["kind"];
|
||||
fileCount: number;
|
||||
decodedBytes: number;
|
||||
context?: DiffArtifactContext;
|
||||
};
|
||||
|
||||
export type DiffRenderedFileArtifactMetadata = {
|
||||
version: 1;
|
||||
kind: "rendered_file";
|
||||
format: DiffOutputFormat;
|
||||
context?: DiffArtifactContext;
|
||||
};
|
||||
|
||||
export type DiffArtifactBlobMetadata =
|
||||
| DiffViewerArtifactMetadata
|
||||
| DiffRenderedFileArtifactMetadata;
|
||||
|
||||
export type DiffArtifactMeta = {
|
||||
id: string;
|
||||
token: string;
|
||||
@@ -120,10 +143,7 @@ export type DiffArtifactMeta = {
|
||||
inputKind: DiffInput["kind"];
|
||||
fileCount: number;
|
||||
viewerPath: string;
|
||||
htmlPath: string;
|
||||
context?: DiffArtifactContext;
|
||||
filePath?: string;
|
||||
imagePath?: string;
|
||||
};
|
||||
|
||||
export const DIFF_ARTIFACT_ID_PATTERN = /^[0-9a-f]{20}$/;
|
||||
|
||||
@@ -4,7 +4,12 @@ import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sd
|
||||
// Process-local runtime store used by voice-call persistence helpers.
|
||||
|
||||
/** Runtime subset needed by voice-call state persistence. */
|
||||
export type VoiceCallStateRuntime = Pick<PluginRuntime, "state">;
|
||||
export type VoiceCallStateRuntime = {
|
||||
state: Pick<
|
||||
PluginRuntime["state"],
|
||||
"resolveStateDir" | "openKeyedStore" | "openSyncKeyedStore" | "openChannelIngressQueue"
|
||||
>;
|
||||
};
|
||||
|
||||
const { setRuntime: setVoiceCallStateRuntime, tryGetRuntime: getOptionalVoiceCallStateRuntime } =
|
||||
createPluginRuntimeStore<VoiceCallStateRuntime>({
|
||||
|
||||
@@ -109,6 +109,8 @@ const legacyStorePatterns = [
|
||||
/\btasks\/(?:runs\.sqlite|flows\/registry\.sqlite)\b/u,
|
||||
/\bopenclaw-state\.sqlite\b/u,
|
||||
/\bopenclaw-native-hook-relays\b/u,
|
||||
/(?:^|\/)(?:meta|file-meta)\.json$/u,
|
||||
/(?:^|\/)viewer\.html$/u,
|
||||
];
|
||||
|
||||
const allowedRuntimeMigrationPaths = [
|
||||
|
||||
@@ -245,8 +245,9 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
// +2: shared channel replay-guard factory and claim handle.
|
||||
// +6: lightweight speech settings types, normalizers, and config resolver.
|
||||
// Harvest: retired AudioConfig type -1.
|
||||
// +4: bounded plugin blob store options, entry, entry info, and store types.
|
||||
// +6: shared progress receipt tracker + compositor snapshot across channel barrels.
|
||||
7997,
|
||||
8001,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
+124
-34
@@ -15,6 +15,7 @@ import {
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { sanitizeOpenClawGlobalStateSnapshot } from "../state/openclaw-state-snapshot-sanitizer.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
createBackupArchive,
|
||||
@@ -196,6 +197,18 @@ describe("formatBackupCreateSummary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeOpenClawGlobalStateSnapshot", () => {
|
||||
it("tolerates legacy databases without current transient tables", () => {
|
||||
const sqlite = requireNodeSqlite();
|
||||
const database = new sqlite.DatabaseSync(":memory:");
|
||||
try {
|
||||
expect(() => sanitizeOpenClawGlobalStateSnapshot(database)).not.toThrow();
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeTarArchiveWithRetry", () => {
|
||||
it.each([
|
||||
new Error("did not encounter expected EOF"),
|
||||
@@ -576,7 +589,7 @@ describe("createBackupArchive", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("scrubs transient SQLite delivery queue rows from archive snapshots", async () => {
|
||||
it("scrubs transient SQLite queue and plugin blob rows from archive snapshots", async () => {
|
||||
await withOpenClawTestState(
|
||||
{
|
||||
layout: "state-only",
|
||||
@@ -596,6 +609,33 @@ describe("createBackupArchive", () => {
|
||||
) VALUES ('outbound', 'queued-1', 'pending', 0, '{"id":"queued-1"}', 10, 10)
|
||||
`,
|
||||
).run();
|
||||
const transientBlobMarker = `transient-diffs-blob-${"sensitive".repeat(32)}`;
|
||||
const durableBlobMarker = "durable-plugin-blob-control";
|
||||
const insertPluginBlob = db.prepare(
|
||||
`
|
||||
INSERT INTO plugin_blob_entries (
|
||||
plugin_id, namespace, entry_key, metadata_json, blob, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
insertPluginBlob.run(
|
||||
"diffs",
|
||||
"viewer-artifacts",
|
||||
"transient",
|
||||
JSON.stringify({ marker: transientBlobMarker }),
|
||||
Buffer.from(`<html>${transientBlobMarker}</html>`),
|
||||
10,
|
||||
Date.UTC(2099, 0, 1),
|
||||
);
|
||||
insertPluginBlob.run(
|
||||
"durable-plugin",
|
||||
"documents",
|
||||
"durable",
|
||||
JSON.stringify({ kind: "durable" }),
|
||||
Buffer.from(durableBlobMarker),
|
||||
10,
|
||||
null,
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await createBackupArchive({
|
||||
@@ -621,13 +661,33 @@ describe("createBackupArchive", () => {
|
||||
expect(
|
||||
archivedDb.prepare("SELECT COUNT(*) AS count FROM delivery_queue_entries").get(),
|
||||
).toEqual({ count: 0 });
|
||||
expect(
|
||||
archivedDb
|
||||
.prepare(
|
||||
"SELECT plugin_id, entry_key FROM plugin_blob_entries ORDER BY plugin_id, entry_key",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ plugin_id: "durable-plugin", entry_key: "durable" }]);
|
||||
} finally {
|
||||
archivedDb.close();
|
||||
}
|
||||
const archivedBytes = await fs.readFile(path.join(extractDir, archivedDbEntry!));
|
||||
expect(archivedBytes.includes(transientBlobMarker)).toBe(false);
|
||||
expect(archivedBytes.includes(durableBlobMarker)).toBe(true);
|
||||
|
||||
expect(db.prepare("SELECT COUNT(*) AS count FROM delivery_queue_entries").get()).toEqual({
|
||||
count: 1,
|
||||
});
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT plugin_id, entry_key FROM plugin_blob_entries ORDER BY plugin_id, entry_key",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ plugin_id: "diffs", entry_key: "transient" },
|
||||
{ plugin_id: "durable-plugin", entry_key: "durable" },
|
||||
]);
|
||||
} finally {
|
||||
closeOpenClawStateDatabase();
|
||||
}
|
||||
@@ -1241,7 +1301,7 @@ describe("createBackupArchive", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("snapshots the canonical global SQLite symlink as a complete regular file", async () => {
|
||||
it("sanitizes every in-state symlink and hardlink alias of the canonical global SQLite DB", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
@@ -1255,8 +1315,9 @@ describe("createBackupArchive", () => {
|
||||
async (state) => {
|
||||
const outputDir = state.path("backups");
|
||||
const extractDir = state.path("extract");
|
||||
const externalDbPath = path.join(state.workspaceDir, "external-global.sqlite");
|
||||
const backingDbPath = state.statePath("state", "backing-global.sqlite");
|
||||
const linkedDbPath = state.statePath("state", "openclaw.sqlite");
|
||||
const hardlinkedDbPath = state.statePath("state", "hardlinked-global.sqlite");
|
||||
await state.writeConfig({
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace: state.workspaceDir }],
|
||||
@@ -1266,7 +1327,8 @@ describe("createBackupArchive", () => {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
const sqlite = requireNodeSqlite();
|
||||
const db = new sqlite.DatabaseSync(externalDbPath);
|
||||
const transientBlobMarker = `aliased-transient-blob-${"sensitive".repeat(32)}`;
|
||||
const db = new sqlite.DatabaseSync(backingDbPath);
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA wal_autocheckpoint = 0;
|
||||
@@ -1277,6 +1339,16 @@ describe("createBackupArchive", () => {
|
||||
CREATE TABLE delivery_queue_entries (
|
||||
id TEXT PRIMARY KEY
|
||||
);
|
||||
CREATE TABLE plugin_blob_entries (
|
||||
plugin_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
entry_key TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
blob BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
PRIMARY KEY (plugin_id, namespace, entry_key)
|
||||
);
|
||||
CREATE TABLE schema_meta (
|
||||
meta_key TEXT NOT NULL PRIMARY KEY,
|
||||
role TEXT NOT NULL
|
||||
@@ -1286,7 +1358,23 @@ describe("createBackupArchive", () => {
|
||||
INSERT INTO durable_state (id, value) VALUES (1, 'must-stay');
|
||||
INSERT INTO delivery_queue_entries (id) VALUES ('must-drop');
|
||||
`);
|
||||
await fs.symlink(externalDbPath, linkedDbPath);
|
||||
db.prepare(
|
||||
`INSERT INTO plugin_blob_entries
|
||||
(plugin_id, namespace, entry_key, metadata_json, blob, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
"diffs",
|
||||
"diff-artifacts",
|
||||
"transient",
|
||||
JSON.stringify({ marker: transientBlobMarker }),
|
||||
Buffer.from(transientBlobMarker),
|
||||
1,
|
||||
Date.UTC(2099, 0, 1),
|
||||
);
|
||||
await fs.symlink(backingDbPath, linkedDbPath);
|
||||
await fs.link(backingDbPath, hardlinkedDbPath);
|
||||
expect((await fs.stat(`${backingDbPath}-wal`)).size).toBeGreaterThan(0);
|
||||
await expect(fs.stat(`${hardlinkedDbPath}-wal`)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
try {
|
||||
const result = await createBackupArchive({
|
||||
@@ -1295,43 +1383,45 @@ describe("createBackupArchive", () => {
|
||||
nowMs: Date.UTC(2026, 4, 9, 8, 34, 30),
|
||||
});
|
||||
const entries = await listArchiveEntryDetails(result.archivePath);
|
||||
const archivedDbEntries = entries.filter((entry) =>
|
||||
entry.path.endsWith("/state/state/openclaw.sqlite"),
|
||||
const archivedDbEntries = entries.filter(
|
||||
(entry) =>
|
||||
entry.path.endsWith("/state/state/openclaw.sqlite") ||
|
||||
entry.path.endsWith("/state/state/backing-global.sqlite") ||
|
||||
entry.path.endsWith("/state/state/hardlinked-global.sqlite"),
|
||||
);
|
||||
expect(archivedDbEntries).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "File",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "File",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "File",
|
||||
}),
|
||||
]);
|
||||
for (const suffix of ["", "-wal", "-shm", "-journal"]) {
|
||||
expect(
|
||||
entries.some((entry) =>
|
||||
entry.path.endsWith(`/workspace/external-global.sqlite${suffix}`),
|
||||
),
|
||||
suffix || "database",
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
await tar.x({ file: result.archivePath, gzip: true, cwd: extractDir });
|
||||
const archivedDb = new sqlite.DatabaseSync(
|
||||
path.join(
|
||||
extractDir,
|
||||
expectDefined(archivedDbEntries[0], "archivedDbEntries[0] test invariant").path,
|
||||
),
|
||||
{ readOnly: true },
|
||||
);
|
||||
try {
|
||||
expect(archivedDb.prepare("PRAGMA integrity_check").get()).toEqual({
|
||||
integrity_check: "ok",
|
||||
});
|
||||
expect(
|
||||
archivedDb.prepare("SELECT value FROM durable_state WHERE id = 1").get(),
|
||||
).toEqual({ value: "must-stay" });
|
||||
expect(
|
||||
archivedDb.prepare("SELECT COUNT(*) AS count FROM delivery_queue_entries").get(),
|
||||
).toEqual({ count: 0 });
|
||||
} finally {
|
||||
archivedDb.close();
|
||||
for (const archivedDbEntry of archivedDbEntries) {
|
||||
const archivedPath = path.join(extractDir, archivedDbEntry.path);
|
||||
expect((await fs.readFile(archivedPath)).includes(transientBlobMarker)).toBe(false);
|
||||
const archivedDb = new sqlite.DatabaseSync(archivedPath, { readOnly: true });
|
||||
try {
|
||||
expect(archivedDb.prepare("PRAGMA integrity_check").get()).toEqual({
|
||||
integrity_check: "ok",
|
||||
});
|
||||
expect(
|
||||
archivedDb.prepare("SELECT value FROM durable_state WHERE id = 1").get(),
|
||||
).toEqual({ value: "must-stay" });
|
||||
expect(
|
||||
archivedDb.prepare("SELECT COUNT(*) AS count FROM delivery_queue_entries").get(),
|
||||
).toEqual({ count: 0 });
|
||||
expect(
|
||||
archivedDb.prepare("SELECT COUNT(*) AS count FROM plugin_blob_entries").get(),
|
||||
).toEqual({ count: 0 });
|
||||
} finally {
|
||||
archivedDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
+21
-22
@@ -4,7 +4,6 @@ import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { resolveDateTimestampMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import {
|
||||
buildBackupArchiveBasename,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
import { isPathWithin } from "../commands/cleanup-utils.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import { sanitizeOpenClawGlobalStateSnapshot } from "../state/openclaw-state-snapshot-sanitizer.js";
|
||||
import { resolveHomeDir, resolveUserPath } from "../utils.js";
|
||||
import { resolveRuntimeServiceVersion } from "../version.js";
|
||||
import { writeArchiveStreamToFile } from "./backup-create-stream.js";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { isVolatileBackupPath } from "./backup-volatile-filter.js";
|
||||
import { createBackupVolatileStatCache } from "./backup-volatile-stat-cache.js";
|
||||
import { formatErrorMessage } from "./errors.js";
|
||||
import { sameFileIdentity } from "./fs-safe-advanced.js";
|
||||
import { writeJson } from "./json-files.js";
|
||||
import { createVerifiedSqliteSnapshot } from "./sqlite-snapshot.js";
|
||||
|
||||
@@ -492,19 +493,6 @@ function isBackupTarFilterFile(entry: import("node:fs").Stats | import("tar").Re
|
||||
return "isFile" in entry ? entry.isFile() : entry.type === "File";
|
||||
}
|
||||
|
||||
function tableExistsSql(db: DatabaseSync, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { ok?: unknown } | undefined;
|
||||
return row?.ok === 1;
|
||||
}
|
||||
|
||||
function sanitizeGlobalStateSqliteSnapshot(db: DatabaseSync): void {
|
||||
if (tableExistsSql(db, "delivery_queue_entries")) {
|
||||
db.prepare("DELETE FROM delivery_queue_entries").run();
|
||||
}
|
||||
}
|
||||
|
||||
async function listStateSqlitePaths(params: {
|
||||
stateDir: string;
|
||||
globalStateSqlitePath: string;
|
||||
@@ -611,25 +599,36 @@ async function createStateSqliteBackupPlan(params: {
|
||||
globalStateSqlitePath,
|
||||
preservedStatePaths: params.preservedStatePaths,
|
||||
});
|
||||
const globalStateIdentity = await fs.stat(globalStateSqlitePath).catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const canonicalGlobalSourcePath = globalStateIdentity
|
||||
? await fs.realpath(globalStateSqlitePath)
|
||||
: globalStateSqlitePath;
|
||||
const snapshots: SqliteBackupAsset[] = [];
|
||||
for (const archiveSourcePath of discovery.snapshotPaths) {
|
||||
// A discovered *.sqlite file that SQLite cannot snapshot aborts backup.
|
||||
// Raw-copying malformed or unreadable databases would restore unsafe state.
|
||||
// Resolve the canonical global path so a symlinked DB reads the target's
|
||||
// live WAL/SHM state instead of looking for sidecars beside the symlink.
|
||||
const sourceDatabasePath =
|
||||
path.resolve(archiveSourcePath) === globalStateSqlitePath
|
||||
? await fs.realpath(archiveSourcePath)
|
||||
: archiveSourcePath;
|
||||
const archiveSourceIdentity = await fs.stat(archiveSourcePath);
|
||||
const isGlobalStateDatabase =
|
||||
globalStateIdentity !== undefined &&
|
||||
sameFileIdentity(globalStateIdentity, archiveSourceIdentity);
|
||||
// Every hardlink/symlink alias of the canonical global DB must read its
|
||||
// canonical WAL and receive the same transient-row sanitizer.
|
||||
const sourceDatabasePath = isGlobalStateDatabase
|
||||
? canonicalGlobalSourcePath
|
||||
: archiveSourcePath;
|
||||
const sourcePath = path.join(params.tempDir, `openclaw-state-db-${snapshots.length}.sqlite`);
|
||||
try {
|
||||
await createVerifiedSqliteSnapshot({
|
||||
sourcePath: sourceDatabasePath,
|
||||
targetPath: sourcePath,
|
||||
transform:
|
||||
path.resolve(archiveSourcePath) === globalStateSqlitePath
|
||||
? sanitizeGlobalStateSqliteSnapshot
|
||||
: undefined,
|
||||
transform: isGlobalStateDatabase ? sanitizeOpenClawGlobalStateSnapshot : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
|
||||
@@ -13,3 +13,9 @@ export type {
|
||||
PluginStateKeyedStore,
|
||||
PluginStateSyncKeyedStore,
|
||||
} from "../plugin-state/plugin-state-store.js";
|
||||
export type {
|
||||
OpenBlobStoreOptions,
|
||||
PluginBlobEntry,
|
||||
PluginBlobEntryInfo,
|
||||
PluginBlobStore,
|
||||
} from "../plugin-state/plugin-blob-store.js";
|
||||
|
||||
@@ -6,6 +6,10 @@ export {
|
||||
createPluginStateSyncKeyedStore as createPluginStateSyncKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "../plugin-state/plugin-state-store.js";
|
||||
export {
|
||||
createPluginBlobStoreForTests,
|
||||
resetPluginBlobStoreForTests,
|
||||
} from "../plugin-state/plugin-blob-store.js";
|
||||
export { createChannelIngressQueue as createChannelIngressQueueForTests } from "../channels/message/ingress-queue.js";
|
||||
export { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
export type { DB as OpenClawStateKyselyDatabaseForTests } from "../state/openclaw-state-db.generated.js";
|
||||
|
||||
@@ -810,6 +810,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
|
||||
openBlobStore: vi.fn(() => {
|
||||
throw new Error("openBlobStore mock is not configured");
|
||||
}) as unknown as PluginRuntime["state"]["openBlobStore"],
|
||||
openKeyedStore: vi.fn(() => {
|
||||
throw new Error("openKeyedStore mock is not configured");
|
||||
}) as unknown as PluginRuntime["state"]["openKeyedStore"],
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
// SQLite persistence for plugin-owned byte blobs and JSON metadata.
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { resolveExpiresAtMsFromDurationMs } from "@openclaw/normalization-core/number-coercion";
|
||||
import type { Insertable, Selectable } from "kysely";
|
||||
import {
|
||||
executeSqliteQuerySync,
|
||||
executeSqliteQueryTakeFirstSync,
|
||||
getNodeSqliteKysely,
|
||||
} from "../infra/kysely-sync.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import type {
|
||||
PluginBlobOverflowPolicy,
|
||||
PluginBlobStoreErrorCode,
|
||||
PluginBlobStoreOperation,
|
||||
} from "./plugin-blob-store.types.js";
|
||||
import { PluginBlobStoreError } from "./plugin-blob-store.types.js";
|
||||
|
||||
export const MAX_PLUGIN_BLOB_BYTES_PER_ENTRY = 100 * 1024 * 1024;
|
||||
export const MAX_PLUGIN_BLOB_BYTES_PER_PLUGIN = 512 * 1024 * 1024;
|
||||
export const MAX_PLUGIN_BLOB_ENTRIES_PER_PLUGIN = 50_000;
|
||||
|
||||
type PluginBlobTable = OpenClawStateKyselyDatabase["plugin_blob_entries"];
|
||||
type PluginBlobDatabase = Pick<OpenClawStateKyselyDatabase, "plugin_blob_entries">;
|
||||
type PluginBlobRow = Selectable<PluginBlobTable>;
|
||||
|
||||
export type PluginBlobStoredInfo = Pick<
|
||||
PluginBlobRow,
|
||||
"entry_key" | "metadata_json" | "created_at" | "expires_at"
|
||||
> & { size_bytes: number | bigint };
|
||||
|
||||
export type PluginBlobStoredEntry = PluginBlobStoredInfo & { blob: Uint8Array };
|
||||
|
||||
type BlobDescriptor = {
|
||||
entry_key: string;
|
||||
namespace: string;
|
||||
created_at: number;
|
||||
size_bytes: number | bigint;
|
||||
};
|
||||
|
||||
type BlobWriteParams = {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
key: string;
|
||||
bytes: Uint8Array;
|
||||
metadataJson: string;
|
||||
maxEntries: number;
|
||||
maxBytesPerNamespace: number;
|
||||
overflowPolicy: PluginBlobOverflowPolicy;
|
||||
ttlMs?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type ValidateMetadataJson = (metadataJson: string) => void;
|
||||
|
||||
function createError(params: {
|
||||
code: PluginBlobStoreErrorCode;
|
||||
operation: PluginBlobStoreOperation;
|
||||
message: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cause?: unknown;
|
||||
}): PluginBlobStoreError {
|
||||
return new PluginBlobStoreError(params.message, {
|
||||
code: params.code,
|
||||
operation: params.operation,
|
||||
path: resolveOpenClawStateSqlitePath(params.env ?? process.env),
|
||||
cause: params.cause,
|
||||
});
|
||||
}
|
||||
|
||||
function wrapError(
|
||||
error: unknown,
|
||||
operation: PluginBlobStoreOperation,
|
||||
fallbackCode: PluginBlobStoreErrorCode,
|
||||
message: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): PluginBlobStoreError {
|
||||
return error instanceof PluginBlobStoreError
|
||||
? error
|
||||
: createError({ code: fallbackCode, operation, message, env, cause: error });
|
||||
}
|
||||
|
||||
function openDatabase(operation: PluginBlobStoreOperation, env?: NodeJS.ProcessEnv) {
|
||||
try {
|
||||
const database = openOpenClawStateDatabase(env ? { env } : {});
|
||||
return database;
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
operation,
|
||||
"PLUGIN_BLOB_OPEN_FAILED",
|
||||
"Failed to open plugin blob store.",
|
||||
env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function kysely(db: DatabaseSync) {
|
||||
return getNodeSqliteKysely<PluginBlobDatabase>(db);
|
||||
}
|
||||
|
||||
function selectLiveBlob(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace: string; key: string; now: number },
|
||||
): PluginBlobStoredEntry | undefined {
|
||||
return executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely(db)
|
||||
.selectFrom("plugin_blob_entries")
|
||||
.select(["entry_key", "metadata_json", "blob", "created_at", "expires_at"])
|
||||
.select((eb) => eb.fn<number | bigint>("length", ["blob"]).as("size_bytes"))
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where("entry_key", "=", params.key)
|
||||
.where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", params.now)])),
|
||||
);
|
||||
}
|
||||
|
||||
function blobKeyExists(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace: string; key: string },
|
||||
): boolean {
|
||||
return (
|
||||
executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely(db)
|
||||
.selectFrom("plugin_blob_entries")
|
||||
.select("entry_key")
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where("entry_key", "=", params.key),
|
||||
) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function selectLiveInfo(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace: string; now: number },
|
||||
): PluginBlobStoredInfo[] {
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
kysely(db)
|
||||
.selectFrom("plugin_blob_entries")
|
||||
.select(["entry_key", "metadata_json", "created_at", "expires_at"])
|
||||
.select((eb) => eb.fn<number | bigint>("length", ["blob"]).as("size_bytes"))
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", params.now)]))
|
||||
.orderBy("created_at", "asc")
|
||||
.orderBy("entry_key", "asc"),
|
||||
).rows;
|
||||
}
|
||||
|
||||
function selectExpiredKeyInfo(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace: string; key: string; now: number },
|
||||
): PluginBlobStoredInfo | undefined {
|
||||
return executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely(db)
|
||||
.selectFrom("plugin_blob_entries")
|
||||
.select(["entry_key", "metadata_json", "created_at", "expires_at"])
|
||||
.select((eb) => eb.fn<number | bigint>("length", ["blob"]).as("size_bytes"))
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where("entry_key", "=", params.key)
|
||||
.where("expires_at", "is not", null)
|
||||
.where("expires_at", "<=", params.now),
|
||||
);
|
||||
}
|
||||
|
||||
function selectLiveDescriptors(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; now: number; namespace?: string; excludeKey?: string },
|
||||
): BlobDescriptor[] {
|
||||
let query = kysely(db)
|
||||
.selectFrom("plugin_blob_entries")
|
||||
.select(["entry_key", "namespace", "created_at"])
|
||||
.select((eb) => eb.fn<number | bigint>("length", ["blob"]).as("size_bytes"))
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", params.now)]));
|
||||
if (params.namespace !== undefined) {
|
||||
query = query.where("namespace", "=", params.namespace);
|
||||
}
|
||||
if (params.excludeKey !== undefined) {
|
||||
query = query.where("entry_key", "!=", params.excludeKey);
|
||||
}
|
||||
return executeSqliteQuerySync(db, query.orderBy("created_at", "asc").orderBy("entry_key", "asc"))
|
||||
.rows;
|
||||
}
|
||||
|
||||
function selectStoredDescriptors(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace?: string },
|
||||
): BlobDescriptor[] {
|
||||
let query = kysely(db)
|
||||
.selectFrom("plugin_blob_entries")
|
||||
.select(["entry_key", "namespace", "created_at"])
|
||||
.select((eb) => eb.fn<number | bigint>("length", ["blob"]).as("size_bytes"))
|
||||
.where("plugin_id", "=", params.pluginId);
|
||||
if (params.namespace !== undefined) {
|
||||
query = query.where("namespace", "=", params.namespace);
|
||||
}
|
||||
return executeSqliteQuerySync(db, query.orderBy("created_at", "asc").orderBy("entry_key", "asc"))
|
||||
.rows;
|
||||
}
|
||||
|
||||
function selectStoredKeyDescriptor(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace: string; key: string },
|
||||
): BlobDescriptor | undefined {
|
||||
return executeSqliteQueryTakeFirstSync(
|
||||
db,
|
||||
kysely(db)
|
||||
.selectFrom("plugin_blob_entries")
|
||||
.select(["entry_key", "namespace", "created_at"])
|
||||
.select((eb) => eb.fn<number | bigint>("length", ["blob"]).as("size_bytes"))
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where("entry_key", "=", params.key),
|
||||
);
|
||||
}
|
||||
|
||||
function deleteKey(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace: string; key: string },
|
||||
): number {
|
||||
const result = executeSqliteQuerySync(
|
||||
db,
|
||||
kysely(db)
|
||||
.deleteFrom("plugin_blob_entries")
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where("entry_key", "=", params.key),
|
||||
);
|
||||
return Number(result.numAffectedRows ?? 0);
|
||||
}
|
||||
|
||||
function deleteKeys(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace: string; keys: readonly string[] },
|
||||
): void {
|
||||
// Stay below conservative SQLite bind-variable limits while avoiding one
|
||||
// DELETE and one array rebuild per evicted row inside the write transaction.
|
||||
const batchSize = 500;
|
||||
for (let offset = 0; offset < params.keys.length; offset += batchSize) {
|
||||
const keys = params.keys.slice(offset, offset + batchSize);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely(db)
|
||||
.deleteFrom("plugin_blob_entries")
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where("entry_key", "in", keys),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteExpiredNamespace(
|
||||
db: DatabaseSync,
|
||||
params: { pluginId: string; namespace: string; now: number },
|
||||
): number {
|
||||
const result = executeSqliteQuerySync(
|
||||
db,
|
||||
kysely(db)
|
||||
.deleteFrom("plugin_blob_entries")
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where("expires_at", "is not", null)
|
||||
.where("expires_at", "<=", params.now),
|
||||
);
|
||||
return Number(result.numAffectedRows ?? 0);
|
||||
}
|
||||
|
||||
function totalBytes(rows: readonly { size_bytes: number | bigint }[]): number {
|
||||
return rows.reduce((total, row) => total + Number(row.size_bytes), 0);
|
||||
}
|
||||
|
||||
function limitError(message: string, env?: NodeJS.ProcessEnv): PluginBlobStoreError {
|
||||
return createError({
|
||||
code: "PLUGIN_BLOB_LIMIT_EXCEEDED",
|
||||
operation: "register",
|
||||
message,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
function assertProjectedLimits(params: {
|
||||
db: DatabaseSync;
|
||||
write: BlobWriteParams;
|
||||
existing?: BlobDescriptor;
|
||||
}): void {
|
||||
// Expired rows remain physically stored until their owner claims cleanup
|
||||
// metadata, so hard storage fuses must count them even though reads hide them.
|
||||
const namespaceRows = selectStoredDescriptors(params.db, {
|
||||
pluginId: params.write.pluginId,
|
||||
namespace: params.write.namespace,
|
||||
});
|
||||
const pluginRows = selectStoredDescriptors(params.db, {
|
||||
pluginId: params.write.pluginId,
|
||||
});
|
||||
const previousBytes = params.existing ? Number(params.existing.size_bytes) : 0;
|
||||
const rowDelta = params.existing ? 0 : 1;
|
||||
if (namespaceRows.length + rowDelta > params.write.maxEntries) {
|
||||
throw limitError("Plugin blob namespace reached its stored row limit.", params.write.env);
|
||||
}
|
||||
if (
|
||||
totalBytes(namespaceRows) - previousBytes + params.write.bytes.byteLength >
|
||||
params.write.maxBytesPerNamespace
|
||||
) {
|
||||
throw limitError("Plugin blob namespace reached its stored byte limit.", params.write.env);
|
||||
}
|
||||
if (pluginRows.length + rowDelta > MAX_PLUGIN_BLOB_ENTRIES_PER_PLUGIN) {
|
||||
throw limitError("Plugin blob store reached its per-plugin row limit.", params.write.env);
|
||||
}
|
||||
if (
|
||||
totalBytes(pluginRows) - previousBytes + params.write.bytes.byteLength >
|
||||
MAX_PLUGIN_BLOB_BYTES_PER_PLUGIN
|
||||
) {
|
||||
throw limitError("Plugin blob store reached its per-plugin byte limit.", params.write.env);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteOldestUntilWithinLimits(params: {
|
||||
db: DatabaseSync;
|
||||
write: BlobWriteParams;
|
||||
now: number;
|
||||
}): void {
|
||||
const namespaceRows = selectStoredDescriptors(params.db, {
|
||||
pluginId: params.write.pluginId,
|
||||
namespace: params.write.namespace,
|
||||
});
|
||||
let namespaceCount = namespaceRows.length;
|
||||
let namespaceBytes = totalBytes(namespaceRows);
|
||||
const namespaceKeysToDelete: string[] = [];
|
||||
// Owner-managed expired rows are not eviction candidates: deleting one would
|
||||
// lose metadata for an external artifact that still needs cleanup.
|
||||
const namespaceCandidates = selectLiveDescriptors(params.db, {
|
||||
pluginId: params.write.pluginId,
|
||||
namespace: params.write.namespace,
|
||||
now: params.now,
|
||||
excludeKey: params.write.key,
|
||||
});
|
||||
for (const row of namespaceCandidates) {
|
||||
if (
|
||||
namespaceCount <= params.write.maxEntries &&
|
||||
namespaceBytes <= params.write.maxBytesPerNamespace
|
||||
) {
|
||||
break;
|
||||
}
|
||||
namespaceKeysToDelete.push(row.entry_key);
|
||||
namespaceCount -= 1;
|
||||
namespaceBytes -= Number(row.size_bytes);
|
||||
}
|
||||
if (
|
||||
namespaceCount > params.write.maxEntries ||
|
||||
namespaceBytes > params.write.maxBytesPerNamespace
|
||||
) {
|
||||
throw limitError(
|
||||
"Plugin blob namespace cannot satisfy its configured limits.",
|
||||
params.write.env,
|
||||
);
|
||||
}
|
||||
deleteKeys(params.db, {
|
||||
pluginId: params.write.pluginId,
|
||||
namespace: params.write.namespace,
|
||||
keys: namespaceKeysToDelete,
|
||||
});
|
||||
|
||||
const pluginRows = selectStoredDescriptors(params.db, {
|
||||
pluginId: params.write.pluginId,
|
||||
});
|
||||
let pluginCount = pluginRows.length;
|
||||
let pluginBytes = totalBytes(pluginRows);
|
||||
// Global hard-cap accounting includes every namespace, but this namespace's
|
||||
// live rows are the only entries its overflow policy may safely evict.
|
||||
const liveNamespaceCandidates = selectLiveDescriptors(params.db, {
|
||||
pluginId: params.write.pluginId,
|
||||
namespace: params.write.namespace,
|
||||
now: params.now,
|
||||
excludeKey: params.write.key,
|
||||
});
|
||||
const pluginKeysToDelete: string[] = [];
|
||||
for (const row of liveNamespaceCandidates) {
|
||||
if (
|
||||
pluginCount <= MAX_PLUGIN_BLOB_ENTRIES_PER_PLUGIN &&
|
||||
pluginBytes <= MAX_PLUGIN_BLOB_BYTES_PER_PLUGIN
|
||||
) {
|
||||
break;
|
||||
}
|
||||
pluginKeysToDelete.push(row.entry_key);
|
||||
pluginCount -= 1;
|
||||
pluginBytes -= Number(row.size_bytes);
|
||||
}
|
||||
if (
|
||||
pluginCount > MAX_PLUGIN_BLOB_ENTRIES_PER_PLUGIN ||
|
||||
pluginBytes > MAX_PLUGIN_BLOB_BYTES_PER_PLUGIN
|
||||
) {
|
||||
throw limitError("Plugin blob store cannot satisfy its per-plugin limits.", params.write.env);
|
||||
}
|
||||
deleteKeys(params.db, {
|
||||
pluginId: params.write.pluginId,
|
||||
namespace: params.write.namespace,
|
||||
keys: pluginKeysToDelete,
|
||||
});
|
||||
}
|
||||
|
||||
function upsertBlob(db: DatabaseSync, params: BlobWriteParams, now: number): void {
|
||||
const expiresAt = (() => {
|
||||
if (params.ttlMs === undefined) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveExpiresAtMsFromDurationMs(params.ttlMs, { nowMs: now });
|
||||
if (resolved === undefined) {
|
||||
throw createError({
|
||||
code: "PLUGIN_BLOB_INVALID_INPUT",
|
||||
operation: "register",
|
||||
message: "Plugin blob ttlMs cannot produce a valid expiry timestamp.",
|
||||
env: params.env,
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
})();
|
||||
const row: Insertable<PluginBlobTable> = {
|
||||
plugin_id: params.pluginId,
|
||||
namespace: params.namespace,
|
||||
entry_key: params.key,
|
||||
metadata_json: params.metadataJson,
|
||||
blob: params.bytes,
|
||||
created_at: now,
|
||||
expires_at: expiresAt,
|
||||
};
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely(db)
|
||||
.insertInto("plugin_blob_entries")
|
||||
.values(row)
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["plugin_id", "namespace", "entry_key"]).doUpdateSet({
|
||||
metadata_json: (eb) => eb.ref("excluded.metadata_json"),
|
||||
blob: (eb) => eb.ref("excluded.blob"),
|
||||
created_at: (eb) => eb.ref("excluded.created_at"),
|
||||
expires_at: (eb) => eb.ref("excluded.expires_at"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function writeBlob(params: BlobWriteParams, ifAbsent: boolean): boolean {
|
||||
try {
|
||||
openDatabase("register", params.env);
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const now = Date.now();
|
||||
if (ifAbsent && blobKeyExists(db, params)) {
|
||||
// Expired rows remain owner-managed until explicitly claimed. Treat
|
||||
// them as occupied so stable-key reuse cannot discard cleanup metadata.
|
||||
return false;
|
||||
}
|
||||
const existing = selectStoredKeyDescriptor(db, params);
|
||||
if (params.overflowPolicy === "reject-new") {
|
||||
assertProjectedLimits({ db, write: params, existing });
|
||||
}
|
||||
upsertBlob(db, params, now);
|
||||
if (params.overflowPolicy === "evict-oldest") {
|
||||
deleteOldestUntilWithinLimits({ db, write: params, now });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
params.env ? { env: params.env } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
"register",
|
||||
"PLUGIN_BLOB_WRITE_FAILED",
|
||||
"Failed to register plugin blob entry.",
|
||||
params.env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginBlobRegister(params: BlobWriteParams): void {
|
||||
writeBlob(params, false);
|
||||
}
|
||||
|
||||
export function pluginBlobRegisterIfAbsent(params: BlobWriteParams): boolean {
|
||||
return writeBlob(params, true);
|
||||
}
|
||||
|
||||
export function pluginBlobLookup(params: {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
key: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): PluginBlobStoredEntry | undefined {
|
||||
try {
|
||||
const { db } = openDatabase("lookup", params.env);
|
||||
return selectLiveBlob(db, { ...params, now: Date.now() });
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
"lookup",
|
||||
"PLUGIN_BLOB_READ_FAILED",
|
||||
"Failed to read plugin blob entry.",
|
||||
params.env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginBlobEntries(params: {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): PluginBlobStoredInfo[] {
|
||||
try {
|
||||
const { db } = openDatabase("entries", params.env);
|
||||
return selectLiveInfo(db, { ...params, now: Date.now() });
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
"entries",
|
||||
"PLUGIN_BLOB_READ_FAILED",
|
||||
"Failed to list plugin blob entries.",
|
||||
params.env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginBlobDelete(params: {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
key: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
try {
|
||||
openDatabase("delete", params.env);
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => deleteKey(db, params) > 0,
|
||||
params.env ? { env: params.env } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
"delete",
|
||||
"PLUGIN_BLOB_WRITE_FAILED",
|
||||
"Failed to delete plugin blob entry.",
|
||||
params.env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginBlobDeleteExpiredKey(params: {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
key: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
validateMetadataJson: ValidateMetadataJson;
|
||||
}): PluginBlobStoredInfo | undefined {
|
||||
try {
|
||||
openDatabase("sweep", params.env);
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const row = selectExpiredKeyInfo(db, { ...params, now: Date.now() });
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
params.validateMetadataJson(row.metadata_json);
|
||||
deleteKey(db, params);
|
||||
return row;
|
||||
},
|
||||
params.env ? { env: params.env } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
"sweep",
|
||||
"PLUGIN_BLOB_WRITE_FAILED",
|
||||
"Failed to delete expired plugin blob.",
|
||||
params.env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginBlobDeleteExpired(params: {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
validateMetadataJson: ValidateMetadataJson;
|
||||
}): PluginBlobStoredInfo[] {
|
||||
try {
|
||||
openDatabase("sweep", params.env);
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const now = Date.now();
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
kysely(db)
|
||||
.selectFrom("plugin_blob_entries")
|
||||
.select(["entry_key", "metadata_json", "created_at", "expires_at"])
|
||||
.select((eb) => eb.fn<number | bigint>("length", ["blob"]).as("size_bytes"))
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace)
|
||||
.where("expires_at", "is not", null)
|
||||
.where("expires_at", "<=", now)
|
||||
.orderBy("created_at", "asc")
|
||||
.orderBy("entry_key", "asc"),
|
||||
).rows;
|
||||
for (const row of rows) {
|
||||
params.validateMetadataJson(row.metadata_json);
|
||||
}
|
||||
deleteExpiredNamespace(db, { ...params, now });
|
||||
return rows;
|
||||
},
|
||||
params.env ? { env: params.env } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
"sweep",
|
||||
"PLUGIN_BLOB_WRITE_FAILED",
|
||||
"Failed to delete expired plugin blobs.",
|
||||
params.env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginBlobClear(params: {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
try {
|
||||
openDatabase("clear", params.env);
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely(db)
|
||||
.deleteFrom("plugin_blob_entries")
|
||||
.where("plugin_id", "=", params.pluginId)
|
||||
.where("namespace", "=", params.namespace),
|
||||
);
|
||||
},
|
||||
params.env ? { env: params.env } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
"clear",
|
||||
"PLUGIN_BLOB_WRITE_FAILED",
|
||||
"Failed to clear plugin blob entries.",
|
||||
params.env,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Plugin blob store tests cover persistence, quotas, expiry, and copied bytes.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
createPluginBlobStoreForTests,
|
||||
resetPluginBlobStoreForTests,
|
||||
type OpenBlobStoreOptions,
|
||||
} from "./plugin-blob-store.js";
|
||||
import { PluginBlobStoreError } from "./plugin-blob-store.types.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetPluginBlobStoreForTests();
|
||||
});
|
||||
|
||||
type TestBlobStoreOptions = OpenBlobStoreOptions & { env: NodeJS.ProcessEnv };
|
||||
|
||||
function options(
|
||||
env: NodeJS.ProcessEnv,
|
||||
overrides: Partial<OpenBlobStoreOptions> = {},
|
||||
): TestBlobStoreOptions {
|
||||
return {
|
||||
namespace: "artifacts",
|
||||
maxEntries: 3,
|
||||
maxBytesPerEntry: 16,
|
||||
maxBytesPerNamespace: 32,
|
||||
env,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginBlobStore<TMetadata>(pluginId: string, testOptions: TestBlobStoreOptions) {
|
||||
const { env, ...storeOptions } = testOptions;
|
||||
return createPluginBlobStoreForTests<TMetadata>(pluginId, storeOptions, env);
|
||||
}
|
||||
|
||||
describe("plugin blob store", () => {
|
||||
it("round-trips metadata and copies bytes on both sides", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-blob-roundtrip" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ kind: string }>("diffs", options(state.env));
|
||||
const source = new Uint8Array([1, 2, 3]);
|
||||
await store.register("viewer", source, { kind: "viewer" });
|
||||
source[0] = 9;
|
||||
|
||||
const first = await store.lookup("viewer");
|
||||
expect(first).toMatchObject({
|
||||
key: "viewer",
|
||||
metadata: { kind: "viewer" },
|
||||
sizeBytes: 3,
|
||||
});
|
||||
expect(first?.bytes).toEqual(new Uint8Array([1, 2, 3]));
|
||||
first!.bytes[0] = 8;
|
||||
expect((await store.lookup("viewer"))?.bytes).toEqual(new Uint8Array([1, 2, 3]));
|
||||
const entries = await store.entries();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({ key: "viewer", metadata: { kind: "viewer" } });
|
||||
expect("bytes" in entries[0]!).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects quota overflow without disturbing existing rows", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-blob-reject" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ order: number }>(
|
||||
"diffs",
|
||||
options(state.env, {
|
||||
maxEntries: 1,
|
||||
maxBytesPerEntry: 4,
|
||||
maxBytesPerNamespace: 4,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
);
|
||||
await store.register("one", new Uint8Array([1, 2]), { order: 1 });
|
||||
await expect(store.register("two", new Uint8Array([3]), { order: 2 })).rejects.toMatchObject({
|
||||
code: "PLUGIN_BLOB_LIMIT_EXCEEDED",
|
||||
});
|
||||
expect((await store.entries()).map((entry) => entry.key)).toEqual(["one"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts the oldest namespace row while protecting the current write", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
await withOpenClawTestState({ label: "plugin-blob-evict" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ order: number }>(
|
||||
"diffs",
|
||||
options(state.env, { maxEntries: 2 }),
|
||||
);
|
||||
await store.register("one", new Uint8Array([1]), { order: 1 });
|
||||
vi.setSystemTime(1_001);
|
||||
await store.register("two", new Uint8Array([2]), { order: 2 });
|
||||
vi.setSystemTime(1_002);
|
||||
await store.register("three", new Uint8Array([3]), { order: 3 });
|
||||
expect((await store.entries()).map((entry) => entry.key)).toEqual(["two", "three"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps expired metadata owner-managed across later writes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(2_000);
|
||||
await withOpenClawTestState({ label: "plugin-blob-expiry" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ order: number }>("diffs", options(state.env));
|
||||
await store.register("one", new Uint8Array([1]), { order: 1 }, { ttlMs: 10 });
|
||||
vi.setSystemTime(2_011);
|
||||
await store.register("two", new Uint8Array([2]), { order: 2 }, { ttlMs: 10 });
|
||||
await expect(store.deleteExpiredKey("one")).resolves.toEqual(
|
||||
expect.objectContaining({ key: "one", metadata: { order: 1 } }),
|
||||
);
|
||||
await expect(store.deleteExpiredKey("two")).resolves.toBeUndefined();
|
||||
await expect(store.deleteExpired()).resolves.toEqual([]);
|
||||
await expect(store.lookup("two")).resolves.toMatchObject({ metadata: { order: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
it("counts expired rows toward physical limits without evicting cleanup metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(2_500);
|
||||
await withOpenClawTestState({ label: "plugin-blob-expired-quota" }, async (state) => {
|
||||
const rejectingStore = createPluginBlobStore<{ path: string }>(
|
||||
"diffs",
|
||||
options(state.env, { maxEntries: 1, overflowPolicy: "reject-new" }),
|
||||
);
|
||||
await rejectingStore.register(
|
||||
"expired",
|
||||
new Uint8Array([1]),
|
||||
{ path: "reject-old" },
|
||||
{ ttlMs: 10 },
|
||||
);
|
||||
vi.setSystemTime(2_511);
|
||||
|
||||
await expect(
|
||||
rejectingStore.register("fresh", new Uint8Array([2]), { path: "reject-new" }),
|
||||
).rejects.toMatchObject({ code: "PLUGIN_BLOB_LIMIT_EXCEEDED" });
|
||||
await expect(rejectingStore.deleteExpiredKey("expired")).resolves.toMatchObject({
|
||||
metadata: { path: "reject-old" },
|
||||
});
|
||||
await expect(
|
||||
rejectingStore.register("fresh", new Uint8Array([2]), { path: "reject-new" }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const evictingStore = createPluginBlobStore<{ path: string }>(
|
||||
"diffs",
|
||||
options(state.env, {
|
||||
namespace: "evicting",
|
||||
maxEntries: 1,
|
||||
overflowPolicy: "evict-oldest",
|
||||
}),
|
||||
);
|
||||
await evictingStore.register(
|
||||
"expired",
|
||||
new Uint8Array([3]),
|
||||
{ path: "evict-old" },
|
||||
{ ttlMs: 10 },
|
||||
);
|
||||
vi.setSystemTime(2_522);
|
||||
|
||||
await expect(
|
||||
evictingStore.register("fresh", new Uint8Array([4]), { path: "evict-new" }),
|
||||
).rejects.toMatchObject({ code: "PLUGIN_BLOB_LIMIT_EXCEEDED" });
|
||||
await expect(evictingStore.deleteExpiredKey("expired")).resolves.toMatchObject({
|
||||
metadata: { path: "evict-old" },
|
||||
});
|
||||
|
||||
const replacingStore = createPluginBlobStore<{ path: string }>(
|
||||
"diffs",
|
||||
options(state.env, {
|
||||
namespace: "replacing",
|
||||
maxBytesPerEntry: 10,
|
||||
maxBytesPerNamespace: 10,
|
||||
overflowPolicy: "evict-oldest",
|
||||
}),
|
||||
);
|
||||
await replacingStore.register(
|
||||
"expired",
|
||||
new Uint8Array(5),
|
||||
{ path: "replace-old" },
|
||||
{ ttlMs: 10 },
|
||||
);
|
||||
await replacingStore.register("target", new Uint8Array(4), { path: "target-old" });
|
||||
vi.setSystemTime(2_533);
|
||||
|
||||
await expect(
|
||||
replacingStore.register("target", new Uint8Array(6), { path: "target-new" }),
|
||||
).rejects.toMatchObject({ code: "PLUGIN_BLOB_LIMIT_EXCEEDED" });
|
||||
await expect(replacingStore.lookup("target")).resolves.toMatchObject({
|
||||
metadata: { path: "target-old" },
|
||||
sizeBytes: 4,
|
||||
});
|
||||
await expect(replacingStore.deleteExpiredKey("expired")).resolves.toMatchObject({
|
||||
metadata: { path: "replace-old" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("validates hard limits and consistent namespace options", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-blob-validation" }, async (state) => {
|
||||
const store = createPluginBlobStore("diffs", options(state.env, { maxBytesPerEntry: 2 }));
|
||||
await expect(store.register("big", new Uint8Array([1, 2, 3]), {})).rejects.toBeInstanceOf(
|
||||
PluginBlobStoreError,
|
||||
);
|
||||
expect(() =>
|
||||
createPluginBlobStore("diffs", options(state.env, { maxBytesPerEntry: 3 })),
|
||||
).toThrow(/incompatible options/);
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates plugin ids and namespaces and persists across reopen", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-blob-isolation" }, async (state) => {
|
||||
const diffs = createPluginBlobStore<{ owner: string }>("diffs", options(state.env));
|
||||
const otherPlugin = createPluginBlobStore<{ owner: string }>("other", options(state.env));
|
||||
const otherNamespace = createPluginBlobStore<{ owner: string }>(
|
||||
"diffs",
|
||||
options(state.env, { namespace: "other-artifacts" }),
|
||||
);
|
||||
await diffs.register("same", new Uint8Array([1]), { owner: "diffs" });
|
||||
|
||||
await expect(otherPlugin.lookup("same")).resolves.toBeUndefined();
|
||||
await expect(otherNamespace.lookup("same")).resolves.toBeUndefined();
|
||||
resetPluginBlobStoreForTests();
|
||||
|
||||
const reopened = createPluginBlobStore<{ owner: string }>("diffs", options(state.env));
|
||||
await expect(reopened.lookup("same")).resolves.toMatchObject({
|
||||
metadata: { owner: "diffs" },
|
||||
bytes: new Uint8Array([1]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the first row when registerIfAbsent loses a collision", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-blob-if-absent" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ order: number }>("diffs", options(state.env));
|
||||
await expect(store.registerIfAbsent("same", new Uint8Array([1]), { order: 1 })).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
await expect(store.registerIfAbsent("same", new Uint8Array([2]), { order: 2 })).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(store.lookup("same")).resolves.toMatchObject({
|
||||
metadata: { order: 1 },
|
||||
bytes: new Uint8Array([1]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an expired stable key occupied until the owner claims its metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(4_000);
|
||||
await withOpenClawTestState({ label: "plugin-blob-expired-if-absent" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ path: string }>("diffs", options(state.env));
|
||||
await expect(
|
||||
store.registerIfAbsent("stable", new Uint8Array([1]), { path: "old" }, { ttlMs: 10 }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
vi.setSystemTime(4_011);
|
||||
await expect(
|
||||
store.registerIfAbsent("stable", new Uint8Array([2]), { path: "new" }),
|
||||
).resolves.toBe(false);
|
||||
await expect(store.deleteExpiredKey("stable")).resolves.toMatchObject({
|
||||
key: "stable",
|
||||
metadata: { path: "old" },
|
||||
});
|
||||
await expect(
|
||||
store.registerIfAbsent("stable", new Uint8Array([2]), { path: "new" }),
|
||||
).resolves.toBe(true);
|
||||
await expect(store.lookup("stable")).resolves.toMatchObject({
|
||||
metadata: { path: "new" },
|
||||
bytes: new Uint8Array([2]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicit register overwrite an expired key", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(4_500);
|
||||
await withOpenClawTestState({ label: "plugin-blob-expired-overwrite" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ version: string }>("diffs", options(state.env));
|
||||
await store.register("stable", new Uint8Array([1]), { version: "old" }, { ttlMs: 10 });
|
||||
|
||||
vi.setSystemTime(4_511);
|
||||
await store.register("stable", new Uint8Array([2]), { version: "new" });
|
||||
|
||||
await expect(store.lookup("stable")).resolves.toMatchObject({
|
||||
metadata: { version: "new" },
|
||||
bytes: new Uint8Array([2]),
|
||||
});
|
||||
await expect(store.deleteExpiredKey("stable")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts by namespace bytes without touching sibling namespaces", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(3_000);
|
||||
await withOpenClawTestState({ label: "plugin-blob-byte-evict" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ order: number }>(
|
||||
"diffs",
|
||||
options(state.env, { maxBytesPerEntry: 3, maxBytesPerNamespace: 3 }),
|
||||
);
|
||||
const sibling = createPluginBlobStore<{ order: number }>(
|
||||
"diffs",
|
||||
options(state.env, {
|
||||
namespace: "sibling",
|
||||
maxBytesPerEntry: 3,
|
||||
maxBytesPerNamespace: 3,
|
||||
}),
|
||||
);
|
||||
await sibling.register("keep", new Uint8Array([9]), { order: 0 });
|
||||
await store.register("one", new Uint8Array([1, 1]), { order: 1 });
|
||||
vi.setSystemTime(3_001);
|
||||
await store.register("two", new Uint8Array([2]), { order: 2 });
|
||||
vi.setSystemTime(3_002);
|
||||
await store.register("three", new Uint8Array([3, 3]), { order: 3 });
|
||||
|
||||
expect((await store.entries()).map((entry) => entry.key)).toEqual(["two", "three"]);
|
||||
expect((await sibling.entries()).map((entry) => entry.key)).toEqual(["keep"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back a rejected replacement and rejects corrupt metadata", async () => {
|
||||
await withOpenClawTestState({ label: "plugin-blob-corrupt" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ ok: boolean }>(
|
||||
"diffs",
|
||||
options(state.env, {
|
||||
maxBytesPerEntry: 3,
|
||||
maxBytesPerNamespace: 3,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
);
|
||||
await store.register("stable", new Uint8Array([1, 2]), { ok: true });
|
||||
await expect(
|
||||
store.register("stable", new Uint8Array([1, 2, 3, 4]), { ok: false }),
|
||||
).rejects.toMatchObject({ code: "PLUGIN_BLOB_LIMIT_EXCEEDED" });
|
||||
await expect(store.lookup("stable")).resolves.toMatchObject({
|
||||
metadata: { ok: true },
|
||||
bytes: new Uint8Array([1, 2]),
|
||||
});
|
||||
|
||||
const { db } = openOpenClawStateDatabase({ env: state.env });
|
||||
db.prepare(
|
||||
`INSERT INTO plugin_blob_entries
|
||||
(plugin_id, namespace, entry_key, metadata_json, blob, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run("diffs", "artifacts", "corrupt", "{", Buffer.from([7]), 1, null);
|
||||
await expect(store.lookup("corrupt")).rejects.toMatchObject({
|
||||
code: "PLUGIN_BLOB_CORRUPT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves expired rows when owner metadata is corrupt", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(5_000);
|
||||
await withOpenClawTestState({ label: "plugin-blob-corrupt-expired" }, async (state) => {
|
||||
const store = createPluginBlobStore<{ path: string }>("diffs", options(state.env));
|
||||
await store.register("valid", new Uint8Array([1]), { path: "valid" }, { ttlMs: 10 });
|
||||
const { db } = openOpenClawStateDatabase({ env: state.env });
|
||||
db.prepare(
|
||||
`INSERT INTO plugin_blob_entries
|
||||
(plugin_id, namespace, entry_key, metadata_json, blob, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run("diffs", "artifacts", "corrupt", "{", Buffer.from([7]), 5_000, 5_010);
|
||||
|
||||
vi.setSystemTime(5_011);
|
||||
await expect(store.deleteExpired()).rejects.toMatchObject({ code: "PLUGIN_BLOB_CORRUPT" });
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT entry_key FROM plugin_blob_entries
|
||||
WHERE plugin_id = ? AND namespace = ? ORDER BY entry_key`,
|
||||
)
|
||||
.all("diffs", "artifacts"),
|
||||
).toEqual([{ entry_key: "corrupt" }, { entry_key: "valid" }]);
|
||||
|
||||
await expect(store.deleteExpiredKey("corrupt")).rejects.toMatchObject({
|
||||
code: "PLUGIN_BLOB_CORRUPT",
|
||||
});
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM plugin_blob_entries
|
||||
WHERE plugin_id = ? AND namespace = ? AND entry_key = ?`,
|
||||
)
|
||||
.get("diffs", "artifacts", "corrupt"),
|
||||
).toEqual({ count: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
// Public facade for plugin-scoped SQLite blob storage.
|
||||
import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js";
|
||||
import {
|
||||
MAX_PLUGIN_BLOB_BYTES_PER_ENTRY,
|
||||
MAX_PLUGIN_BLOB_BYTES_PER_PLUGIN,
|
||||
MAX_PLUGIN_BLOB_ENTRIES_PER_PLUGIN,
|
||||
pluginBlobClear,
|
||||
pluginBlobDelete,
|
||||
pluginBlobDeleteExpiredKey,
|
||||
pluginBlobDeleteExpired,
|
||||
pluginBlobEntries,
|
||||
pluginBlobLookup,
|
||||
pluginBlobRegister,
|
||||
pluginBlobRegisterIfAbsent,
|
||||
type PluginBlobStoredEntry,
|
||||
type PluginBlobStoredInfo,
|
||||
} from "./plugin-blob-store.sqlite.js";
|
||||
import type {
|
||||
OpenBlobStoreOptions,
|
||||
PluginBlobEntry,
|
||||
PluginBlobEntryInfo,
|
||||
PluginBlobOverflowPolicy,
|
||||
PluginBlobStore,
|
||||
PluginBlobStoreOperation,
|
||||
} from "./plugin-blob-store.types.js";
|
||||
import { PluginBlobStoreError } from "./plugin-blob-store.types.js";
|
||||
import {
|
||||
serializePluginStoreJson,
|
||||
validateOptionalPluginStoreTtlMs,
|
||||
validatePluginStoreKey,
|
||||
validatePluginStoreNamespace,
|
||||
validatePluginStorePositiveInteger,
|
||||
} from "./plugin-store-validation.js";
|
||||
|
||||
export type {
|
||||
OpenBlobStoreOptions,
|
||||
PluginBlobEntry,
|
||||
PluginBlobEntryInfo,
|
||||
PluginBlobStore,
|
||||
} from "./plugin-blob-store.types.js";
|
||||
|
||||
type BlobStoreOptionSignature = {
|
||||
maxEntries: number;
|
||||
maxBytesPerEntry: number;
|
||||
maxBytesPerNamespace: number;
|
||||
overflowPolicy: PluginBlobOverflowPolicy;
|
||||
defaultTtlMs?: number;
|
||||
};
|
||||
|
||||
type PreparedBlob = {
|
||||
key: string;
|
||||
bytes: Uint8Array;
|
||||
metadataJson: string;
|
||||
ttlMs?: number;
|
||||
};
|
||||
|
||||
const namespaceOptionSignatures = new Map<string, BlobStoreOptionSignature>();
|
||||
|
||||
function invalidInput(
|
||||
message: string,
|
||||
operation: PluginBlobStoreOperation = "register",
|
||||
): PluginBlobStoreError {
|
||||
return new PluginBlobStoreError(message, {
|
||||
code: "PLUGIN_BLOB_INVALID_INPUT",
|
||||
operation,
|
||||
});
|
||||
}
|
||||
|
||||
function limitError(message: string): PluginBlobStoreError {
|
||||
return new PluginBlobStoreError(message, {
|
||||
code: "PLUGIN_BLOB_LIMIT_EXCEEDED",
|
||||
operation: "register",
|
||||
});
|
||||
}
|
||||
|
||||
const validationErrors = (operation: PluginBlobStoreOperation) => ({
|
||||
invalid: (message: string) => invalidInput(message, operation),
|
||||
limit: (message: string) => limitError(message),
|
||||
});
|
||||
|
||||
function validateNamespace(value: string): string {
|
||||
return validatePluginStoreNamespace({
|
||||
value,
|
||||
label: "plugin blob",
|
||||
errors: validationErrors("open"),
|
||||
});
|
||||
}
|
||||
|
||||
function validateKey(value: string, operation: PluginBlobStoreOperation): string {
|
||||
return validatePluginStoreKey({
|
||||
value,
|
||||
label: "plugin blob",
|
||||
errors: validationErrors(operation),
|
||||
});
|
||||
}
|
||||
|
||||
function validatePositiveLimit(value: number, label: string, maximum: number): number {
|
||||
const normalized = validatePluginStorePositiveInteger({
|
||||
value,
|
||||
label,
|
||||
errors: validationErrors("open"),
|
||||
});
|
||||
if (normalized > maximum) {
|
||||
throw invalidInput(`${label} must be <= ${maximum}`, "open");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateOverflowPolicy(value: unknown): PluginBlobOverflowPolicy {
|
||||
if (value === undefined || value === "evict-oldest") {
|
||||
return "evict-oldest";
|
||||
}
|
||||
if (value === "reject-new") {
|
||||
return value;
|
||||
}
|
||||
throw invalidInput("plugin blob overflowPolicy must be evict-oldest or reject-new", "open");
|
||||
}
|
||||
|
||||
function validateTtl(
|
||||
value: number | undefined,
|
||||
operation: PluginBlobStoreOperation,
|
||||
): number | undefined {
|
||||
return validateOptionalPluginStoreTtlMs({
|
||||
value,
|
||||
label: "plugin blob ttlMs",
|
||||
errors: validationErrors(operation),
|
||||
});
|
||||
}
|
||||
|
||||
function assertConsistentOptions(
|
||||
pluginId: string,
|
||||
namespace: string,
|
||||
signature: BlobStoreOptionSignature,
|
||||
): void {
|
||||
const key = `${pluginId}\0${namespace}`;
|
||||
const existing = namespaceOptionSignatures.get(key);
|
||||
if (!existing) {
|
||||
namespaceOptionSignatures.set(key, signature);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
existing.maxEntries !== signature.maxEntries ||
|
||||
existing.maxBytesPerEntry !== signature.maxBytesPerEntry ||
|
||||
existing.maxBytesPerNamespace !== signature.maxBytesPerNamespace ||
|
||||
existing.overflowPolicy !== signature.overflowPolicy ||
|
||||
existing.defaultTtlMs !== signature.defaultTtlMs
|
||||
) {
|
||||
// Namespace limits are a shared contract. Reopening with different limits
|
||||
// would make quota and eviction behavior depend on call order.
|
||||
throw invalidInput(
|
||||
`plugin blob namespace ${namespace} for ${pluginId} was reopened with incompatible options`,
|
||||
"open",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function prepareBlob(params: {
|
||||
key: string;
|
||||
bytes: Uint8Array;
|
||||
metadata: unknown;
|
||||
maxBytesPerEntry: number;
|
||||
defaultTtlMs?: number;
|
||||
opts?: { ttlMs?: number };
|
||||
}): PreparedBlob {
|
||||
const key = validateKey(params.key, "register");
|
||||
if (!(params.bytes instanceof Uint8Array)) {
|
||||
throw invalidInput("plugin blob bytes must be a Uint8Array");
|
||||
}
|
||||
if (params.bytes.byteLength > params.maxBytesPerEntry) {
|
||||
throw limitError(
|
||||
`plugin blob entry exceeds the configured ${params.maxBytesPerEntry} byte limit`,
|
||||
);
|
||||
}
|
||||
const metadataJson = serializePluginStoreJson({
|
||||
value: params.metadata,
|
||||
label: "plugin blob metadata",
|
||||
errors: validationErrors("register"),
|
||||
});
|
||||
const ttlMs = validateTtl(params.opts?.ttlMs, "register") ?? params.defaultTtlMs;
|
||||
return {
|
||||
key,
|
||||
bytes: Uint8Array.from(params.bytes),
|
||||
metadataJson,
|
||||
...(ttlMs !== undefined ? { ttlMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMetadata(
|
||||
raw: string,
|
||||
operation: PluginBlobStoreOperation,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): unknown {
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch (error) {
|
||||
throw new PluginBlobStoreError("Plugin blob entry contains corrupt metadata JSON.", {
|
||||
code: "PLUGIN_BLOB_CORRUPT",
|
||||
operation,
|
||||
path: resolveOpenClawStateSqlitePath(env ?? process.env),
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function storedInfoToEntryInfo<TMetadata>(
|
||||
row: PluginBlobStoredInfo,
|
||||
operation: PluginBlobStoreOperation,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): PluginBlobEntryInfo<TMetadata> {
|
||||
const expiresAt = normalizeSqliteNumber(row.expires_at);
|
||||
return {
|
||||
key: row.entry_key,
|
||||
metadata: parseMetadata(row.metadata_json, operation, env) as TMetadata,
|
||||
sizeBytes: Number(row.size_bytes),
|
||||
createdAt: normalizeSqliteNumber(row.created_at) ?? 0,
|
||||
...(expiresAt != null ? { expiresAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function storedEntryToEntry<TMetadata>(
|
||||
row: PluginBlobStoredEntry,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): PluginBlobEntry<TMetadata> {
|
||||
return {
|
||||
...storedInfoToEntryInfo<TMetadata>(row, "lookup", env),
|
||||
bytes: Uint8Array.from(row.blob),
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginBlobStoreInternal<TMetadata>(
|
||||
pluginId: string,
|
||||
options: OpenBlobStoreOptions,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): PluginBlobStore<TMetadata> {
|
||||
if (pluginId.startsWith("core:")) {
|
||||
throw invalidInput("Plugin ids starting with 'core:' are reserved for core consumers.", "open");
|
||||
}
|
||||
const namespace = validateNamespace(options.namespace);
|
||||
const maxEntries = validatePositiveLimit(
|
||||
options.maxEntries,
|
||||
"plugin blob maxEntries",
|
||||
MAX_PLUGIN_BLOB_ENTRIES_PER_PLUGIN,
|
||||
);
|
||||
const maxBytesPerEntry = validatePositiveLimit(
|
||||
options.maxBytesPerEntry,
|
||||
"plugin blob maxBytesPerEntry",
|
||||
MAX_PLUGIN_BLOB_BYTES_PER_ENTRY,
|
||||
);
|
||||
const maxBytesPerNamespace = validatePositiveLimit(
|
||||
options.maxBytesPerNamespace,
|
||||
"plugin blob maxBytesPerNamespace",
|
||||
MAX_PLUGIN_BLOB_BYTES_PER_PLUGIN,
|
||||
);
|
||||
if (maxBytesPerEntry > maxBytesPerNamespace) {
|
||||
throw invalidInput("plugin blob maxBytesPerEntry must not exceed maxBytesPerNamespace", "open");
|
||||
}
|
||||
const overflowPolicy = validateOverflowPolicy(options.overflowPolicy);
|
||||
const defaultTtlMs = validateTtl(options.defaultTtlMs, "open");
|
||||
assertConsistentOptions(pluginId, namespace, {
|
||||
maxEntries,
|
||||
maxBytesPerEntry,
|
||||
maxBytesPerNamespace,
|
||||
overflowPolicy,
|
||||
defaultTtlMs,
|
||||
});
|
||||
|
||||
const writeParams = (blob: PreparedBlob) => ({
|
||||
pluginId,
|
||||
namespace,
|
||||
key: blob.key,
|
||||
bytes: blob.bytes,
|
||||
metadataJson: blob.metadataJson,
|
||||
maxEntries,
|
||||
maxBytesPerNamespace,
|
||||
overflowPolicy,
|
||||
...(blob.ttlMs !== undefined ? { ttlMs: blob.ttlMs } : {}),
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
async register(key, bytes, metadata, opts) {
|
||||
const blob = prepareBlob({
|
||||
key,
|
||||
bytes,
|
||||
metadata,
|
||||
maxBytesPerEntry,
|
||||
defaultTtlMs,
|
||||
opts,
|
||||
});
|
||||
pluginBlobRegister(writeParams(blob));
|
||||
},
|
||||
async registerIfAbsent(key, bytes, metadata, opts) {
|
||||
const blob = prepareBlob({
|
||||
key,
|
||||
bytes,
|
||||
metadata,
|
||||
maxBytesPerEntry,
|
||||
defaultTtlMs,
|
||||
opts,
|
||||
});
|
||||
return pluginBlobRegisterIfAbsent(writeParams(blob));
|
||||
},
|
||||
async lookup(key) {
|
||||
const row = pluginBlobLookup({
|
||||
pluginId,
|
||||
namespace,
|
||||
key: validateKey(key, "lookup"),
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
return row ? storedEntryToEntry<TMetadata>(row, env) : undefined;
|
||||
},
|
||||
async entries() {
|
||||
return pluginBlobEntries({ pluginId, namespace, ...(env ? { env } : {}) }).map((row) =>
|
||||
storedInfoToEntryInfo<TMetadata>(row, "entries", env),
|
||||
);
|
||||
},
|
||||
async delete(key) {
|
||||
return pluginBlobDelete({
|
||||
pluginId,
|
||||
namespace,
|
||||
key: validateKey(key, "delete"),
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
},
|
||||
async deleteExpiredKey(key) {
|
||||
const row = pluginBlobDeleteExpiredKey({
|
||||
pluginId,
|
||||
namespace,
|
||||
key: validateKey(key, "sweep"),
|
||||
validateMetadataJson: (raw) => {
|
||||
parseMetadata(raw, "sweep", env);
|
||||
},
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
return row ? storedInfoToEntryInfo<TMetadata>(row, "sweep", env) : undefined;
|
||||
},
|
||||
async deleteExpired() {
|
||||
return pluginBlobDeleteExpired({
|
||||
pluginId,
|
||||
namespace,
|
||||
validateMetadataJson: (raw) => {
|
||||
parseMetadata(raw, "sweep", env);
|
||||
},
|
||||
...(env ? { env } : {}),
|
||||
}).map((row) => storedInfoToEntryInfo<TMetadata>(row, "sweep", env));
|
||||
},
|
||||
async clear() {
|
||||
pluginBlobClear({ pluginId, namespace, ...(env ? { env } : {}) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens an async blob namespace for a non-core plugin id. */
|
||||
export function createPluginBlobStore<TMetadata>(
|
||||
pluginId: string,
|
||||
options: OpenBlobStoreOptions,
|
||||
): PluginBlobStore<TMetadata> {
|
||||
return createPluginBlobStoreInternal<TMetadata>(pluginId, options);
|
||||
}
|
||||
|
||||
/** Test-only factory with an isolated state environment. */
|
||||
export function createPluginBlobStoreForTests<TMetadata>(
|
||||
pluginId: string,
|
||||
options: OpenBlobStoreOptions,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): PluginBlobStore<TMetadata> {
|
||||
return createPluginBlobStoreInternal<TMetadata>(pluginId, options, env);
|
||||
}
|
||||
|
||||
/** Resets facade signatures and the shared state database handle for tests. */
|
||||
export function resetPluginBlobStoreForTests(options: { closeDatabase?: boolean } = {}): void {
|
||||
namespaceOptionSignatures.clear();
|
||||
if (options.closeDatabase !== false) {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Public plugin blob-store contracts. Stores are scoped by plugin id and namespace.
|
||||
export type PluginBlobEntryInfo<TMetadata> = {
|
||||
key: string;
|
||||
metadata: TMetadata;
|
||||
sizeBytes: number;
|
||||
createdAt: number;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export type PluginBlobEntry<TMetadata> = PluginBlobEntryInfo<TMetadata> & {
|
||||
bytes: Uint8Array;
|
||||
};
|
||||
|
||||
export type PluginBlobStore<TMetadata> = {
|
||||
register(
|
||||
key: string,
|
||||
bytes: Uint8Array,
|
||||
metadata: TMetadata,
|
||||
opts?: { ttlMs?: number },
|
||||
): Promise<void>;
|
||||
registerIfAbsent(
|
||||
key: string,
|
||||
bytes: Uint8Array,
|
||||
metadata: TMetadata,
|
||||
opts?: { ttlMs?: number },
|
||||
): Promise<boolean>;
|
||||
lookup(key: string): Promise<PluginBlobEntry<TMetadata> | undefined>;
|
||||
entries(): Promise<PluginBlobEntryInfo<TMetadata>[]>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
deleteExpiredKey(key: string): Promise<PluginBlobEntryInfo<TMetadata> | undefined>;
|
||||
deleteExpired(): Promise<PluginBlobEntryInfo<TMetadata>[]>;
|
||||
clear(): Promise<void>;
|
||||
};
|
||||
|
||||
export type PluginBlobOverflowPolicy = "evict-oldest" | "reject-new";
|
||||
|
||||
export type OpenBlobStoreOptions = {
|
||||
namespace: string;
|
||||
maxEntries: number;
|
||||
maxBytesPerEntry: number;
|
||||
maxBytesPerNamespace: number;
|
||||
overflowPolicy?: PluginBlobOverflowPolicy;
|
||||
defaultTtlMs?: number;
|
||||
};
|
||||
|
||||
export type PluginBlobStoreErrorCode =
|
||||
| "PLUGIN_BLOB_OPEN_FAILED"
|
||||
| "PLUGIN_BLOB_WRITE_FAILED"
|
||||
| "PLUGIN_BLOB_READ_FAILED"
|
||||
| "PLUGIN_BLOB_CORRUPT"
|
||||
| "PLUGIN_BLOB_LIMIT_EXCEEDED"
|
||||
| "PLUGIN_BLOB_INVALID_INPUT";
|
||||
|
||||
export type PluginBlobStoreOperation =
|
||||
| "open"
|
||||
| "register"
|
||||
| "lookup"
|
||||
| "delete"
|
||||
| "entries"
|
||||
| "clear"
|
||||
| "sweep";
|
||||
|
||||
export class PluginBlobStoreError extends Error {
|
||||
readonly code: PluginBlobStoreErrorCode;
|
||||
readonly operation: PluginBlobStoreOperation;
|
||||
readonly path?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
code: PluginBlobStoreErrorCode;
|
||||
operation: PluginBlobStoreOperation;
|
||||
path?: string;
|
||||
cause?: unknown;
|
||||
},
|
||||
) {
|
||||
super(message, { cause: options.cause });
|
||||
this.name = "PluginBlobStoreError";
|
||||
this.code = options.code;
|
||||
this.operation = options.operation;
|
||||
if (options.path) {
|
||||
this.path = options.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import { resolveStateDir } from "../config/paths.js";
|
||||
import type { PluginRecord } from "../plugins/registry-types.js";
|
||||
import { createPluginRegistry } from "../plugins/registry.js";
|
||||
import type { PluginRuntime } from "../plugins/runtime/types.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(
|
||||
@@ -55,6 +57,9 @@ function createTestPluginRegistry() {
|
||||
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");
|
||||
},
|
||||
@@ -67,6 +72,7 @@ function createTestPluginRegistry() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginBlobStoreForTests();
|
||||
resetPluginStateStoreForTests();
|
||||
});
|
||||
|
||||
@@ -121,6 +127,75 @@ describe("plugin runtime state proxy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("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");
|
||||
@@ -133,16 +208,32 @@ describe("plugin runtime state proxy", () => {
|
||||
expect(() =>
|
||||
api.runtime.state.openSyncKeyedStore({ 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");
|
||||
});
|
||||
|
||||
it("rejects untrusted global plugins", () => {
|
||||
const registry = createTestPluginRegistry();
|
||||
const record = createPluginRecord("external-plugin", "global");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,12 @@ import type {
|
||||
PluginStateStoreOperation,
|
||||
} from "./plugin-state-store.types.js";
|
||||
import { PluginStateStoreError } from "./plugin-state-store.types.js";
|
||||
import {
|
||||
serializePluginStoreJson,
|
||||
validateOptionalPluginStoreTtlMs,
|
||||
validatePluginStoreKey,
|
||||
validatePluginStoreNamespace,
|
||||
} from "./plugin-store-validation.js";
|
||||
|
||||
// Public plugin-state facade over the sqlite-backed store. It validates plugin
|
||||
// ids, namespaces, JSON values, TTLs, and per-plugin limits before persistence.
|
||||
@@ -43,11 +49,6 @@ export {
|
||||
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;
|
||||
overflowPolicy: PluginStateOverflowPolicy;
|
||||
@@ -61,8 +62,6 @@ type PreparedRegisterParams = {
|
||||
};
|
||||
|
||||
const namespaceOptionSignatures = new Map<string, StoreOptionSignature>();
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function invalidInput(
|
||||
message: string,
|
||||
operation: PluginStateStoreOperation = "register",
|
||||
@@ -73,33 +72,26 @@ function invalidInput(
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
return validatePluginStoreNamespace({
|
||||
value,
|
||||
label: "plugin state",
|
||||
errors: {
|
||||
invalid: (message) => invalidInput(message, operation),
|
||||
limit: (message) => invalidInput(message, operation),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
return validatePluginStoreKey({
|
||||
value,
|
||||
label: "plugin state",
|
||||
errors: {
|
||||
invalid: (message) => invalidInput(message, operation),
|
||||
limit: (message) => invalidInput(message, operation),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function validateMaxEntries(value: number): number {
|
||||
@@ -123,96 +115,14 @@ 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",
|
||||
});
|
||||
}
|
||||
return validateOptionalPluginStoreTtlMs({
|
||||
value,
|
||||
label: "plugin state ttlMs",
|
||||
errors: {
|
||||
invalid: (message) => invalidInput(message, operation),
|
||||
limit: (message) => invalidInput(message, operation),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function prepareRegisterParams(
|
||||
@@ -222,12 +132,19 @@ function prepareRegisterParams(
|
||||
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 json = serializePluginStoreJson({
|
||||
value,
|
||||
label: "plugin state value",
|
||||
maxBytes: MAX_PLUGIN_STATE_VALUE_BYTES,
|
||||
errors: {
|
||||
invalid: (message) => invalidInput(message, "register"),
|
||||
limit: (message) =>
|
||||
new PluginStateStoreError(message, {
|
||||
code: "PLUGIN_STATE_LIMIT_EXCEEDED",
|
||||
operation: "register",
|
||||
}),
|
||||
},
|
||||
});
|
||||
const ttlMs = validateOptionalTtlMs(opts?.ttlMs, "register") ?? defaultTtlMs;
|
||||
return {
|
||||
key: normalizedKey,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Shared validation for plugin-owned keyed JSON and blob stores.
|
||||
const MAX_PLUGIN_STORE_NAMESPACE_BYTES = 128;
|
||||
const MAX_PLUGIN_STORE_KEY_BYTES = 512;
|
||||
const MAX_PLUGIN_STORE_JSON_BYTES = 65_536;
|
||||
const MAX_PLUGIN_STORE_JSON_DEPTH = 64;
|
||||
|
||||
const NAMESPACE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/iu;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
type PluginStoreValidationErrors = {
|
||||
invalid(message: string): Error;
|
||||
limit(message: string): Error;
|
||||
};
|
||||
|
||||
function assertMaxUtf8Bytes(params: {
|
||||
label: string;
|
||||
value: string;
|
||||
maxBytes: number;
|
||||
errors: PluginStoreValidationErrors;
|
||||
}): void {
|
||||
if (textEncoder.encode(params.value).byteLength > params.maxBytes) {
|
||||
throw params.errors.invalid(`${params.label} must be <= ${params.maxBytes} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePluginStoreNamespace(params: {
|
||||
value: string;
|
||||
label: string;
|
||||
errors: PluginStoreValidationErrors;
|
||||
}): string {
|
||||
const trimmed = params.value.trim();
|
||||
if (!NAMESPACE_PATTERN.test(trimmed)) {
|
||||
throw params.errors.invalid(
|
||||
`${params.label} namespace must be a safe path segment: ${params.value}`,
|
||||
);
|
||||
}
|
||||
assertMaxUtf8Bytes({
|
||||
label: `${params.label} namespace`,
|
||||
value: trimmed,
|
||||
maxBytes: MAX_PLUGIN_STORE_NAMESPACE_BYTES,
|
||||
errors: params.errors,
|
||||
});
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function validatePluginStoreKey(params: {
|
||||
value: string;
|
||||
label: string;
|
||||
errors: PluginStoreValidationErrors;
|
||||
}): string {
|
||||
const trimmed = params.value.trim();
|
||||
if (!trimmed) {
|
||||
throw params.errors.invalid(`${params.label} entry key must not be empty`);
|
||||
}
|
||||
assertMaxUtf8Bytes({
|
||||
label: `${params.label} entry key`,
|
||||
value: trimmed,
|
||||
maxBytes: MAX_PLUGIN_STORE_KEY_BYTES,
|
||||
errors: params.errors,
|
||||
});
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function validatePluginStorePositiveInteger(params: {
|
||||
value: number;
|
||||
label: string;
|
||||
errors: PluginStoreValidationErrors;
|
||||
}): number {
|
||||
if (!Number.isSafeInteger(params.value) || params.value < 1) {
|
||||
throw params.errors.invalid(`${params.label} must be a positive safe integer`);
|
||||
}
|
||||
return params.value;
|
||||
}
|
||||
|
||||
export function validateOptionalPluginStoreTtlMs(params: {
|
||||
value: number | undefined;
|
||||
label: string;
|
||||
errors: PluginStoreValidationErrors;
|
||||
}): number | undefined {
|
||||
const value = params.value;
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
return validatePluginStorePositiveInteger({ ...params, value });
|
||||
}
|
||||
|
||||
function assertPlainJsonValue(
|
||||
value: unknown,
|
||||
params: {
|
||||
label: string;
|
||||
errors: PluginStoreValidationErrors;
|
||||
seen: WeakSet<object>;
|
||||
path: string;
|
||||
depth: number;
|
||||
},
|
||||
): void {
|
||||
if (params.depth > MAX_PLUGIN_STORE_JSON_DEPTH) {
|
||||
throw params.errors.limit(
|
||||
`${params.label} nesting exceeds maximum depth of ${MAX_PLUGIN_STORE_JSON_DEPTH}`,
|
||||
);
|
||||
}
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
const valueType = typeof value;
|
||||
if (valueType === "string" || valueType === "boolean") {
|
||||
return;
|
||||
}
|
||||
if (valueType === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw params.errors.invalid(`${params.label} at ${params.path} must be a finite number`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (valueType !== "object") {
|
||||
throw params.errors.invalid(`${params.label} at ${params.path} must be JSON-serializable`);
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
if (params.seen.has(objectValue)) {
|
||||
throw params.errors.invalid(
|
||||
`${params.label} at ${params.path} must not contain circular references`,
|
||||
);
|
||||
}
|
||||
params.seen.add(objectValue);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!(index in value)) {
|
||||
throw params.errors.invalid(`${params.label} array at ${params.path} must not be sparse`);
|
||||
}
|
||||
assertPlainJsonValue(value[index], {
|
||||
...params,
|
||||
path: `${params.path}[${index}]`,
|
||||
depth: params.depth + 1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.getPrototypeOf(objectValue) !== Object.prototype) {
|
||||
throw params.errors.invalid(
|
||||
`${params.label} object at ${params.path} must be a plain object`,
|
||||
);
|
||||
}
|
||||
const descriptorEntries = Object.entries(Object.getOwnPropertyDescriptors(objectValue));
|
||||
if (Object.getOwnPropertySymbols(objectValue).length > 0) {
|
||||
throw params.errors.invalid(
|
||||
`${params.label} object at ${params.path} must not use symbol keys`,
|
||||
);
|
||||
}
|
||||
if (descriptorEntries.length !== Object.keys(objectValue).length) {
|
||||
throw params.errors.invalid(
|
||||
`${params.label} object at ${params.path} must not use non-enumerable properties`,
|
||||
);
|
||||
}
|
||||
for (const [key, descriptor] of descriptorEntries) {
|
||||
if (descriptor.get || descriptor.set || !("value" in descriptor)) {
|
||||
throw params.errors.invalid(
|
||||
`${params.label} object at ${params.path}.${key} must use data properties`,
|
||||
);
|
||||
}
|
||||
assertPlainJsonValue(descriptor.value, {
|
||||
...params,
|
||||
path: `${params.path}.${key}`,
|
||||
depth: params.depth + 1,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
params.seen.delete(objectValue);
|
||||
}
|
||||
}
|
||||
|
||||
export function serializePluginStoreJson(params: {
|
||||
value: unknown;
|
||||
label: string;
|
||||
errors: PluginStoreValidationErrors;
|
||||
maxBytes?: number;
|
||||
}): string {
|
||||
assertPlainJsonValue(params.value, {
|
||||
label: params.label,
|
||||
errors: params.errors,
|
||||
seen: new WeakSet<object>(),
|
||||
path: "value",
|
||||
depth: 0,
|
||||
});
|
||||
const json = JSON.stringify(params.value);
|
||||
if (json === undefined) {
|
||||
throw params.errors.invalid(`${params.label} must be JSON-serializable`);
|
||||
}
|
||||
const maxBytes = params.maxBytes ?? MAX_PLUGIN_STORE_JSON_BYTES;
|
||||
if (textEncoder.encode(json).byteLength > maxBytes) {
|
||||
throw params.errors.limit(`${params.label} exceeds ${maxBytes} byte limit`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
@@ -192,4 +192,42 @@ describe("recordPluginInstall", () => {
|
||||
installedAt: "2026-05-15T00:00:03.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces local npm-pack provenance on a registry reinstall", () => {
|
||||
const localInstall = recordPluginInstall(
|
||||
{},
|
||||
{
|
||||
pluginId: "diffs",
|
||||
source: "npm",
|
||||
spec: "/tmp/openclaw-diffs.tgz",
|
||||
sourcePath: "/tmp/openclaw-diffs.tgz",
|
||||
installPath: "/tmp/openclaw/plugins/diffs-local",
|
||||
artifactKind: "npm-pack",
|
||||
artifactFormat: "tgz",
|
||||
npmTarballName: "openclaw-diffs-1.0.0.tgz",
|
||||
installedAt: "2026-05-14T18:00:03.000Z",
|
||||
},
|
||||
);
|
||||
|
||||
const registryInstall = recordPluginInstall(localInstall, {
|
||||
pluginId: "diffs",
|
||||
source: "npm",
|
||||
spec: "@openclaw/diffs",
|
||||
installPath: "/tmp/openclaw/plugins/diffs",
|
||||
resolvedName: "@openclaw/diffs",
|
||||
resolvedVersion: "1.1.0",
|
||||
resolvedSpec: "@openclaw/diffs@1.1.0",
|
||||
installedAt: "2026-05-15T00:00:03.000Z",
|
||||
});
|
||||
|
||||
expect(registryInstall.plugins?.installs?.diffs).toEqual({
|
||||
source: "npm",
|
||||
spec: "@openclaw/diffs",
|
||||
installPath: "/tmp/openclaw/plugins/diffs",
|
||||
resolvedName: "@openclaw/diffs",
|
||||
resolvedVersion: "1.1.0",
|
||||
resolvedSpec: "@openclaw/diffs@1.1.0",
|
||||
installedAt: "2026-05-15T00:00:03.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-25
@@ -7,17 +7,6 @@ import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
||||
/** Plugin install record update with the target plugin id attached. */
|
||||
export type PluginInstallUpdate = PluginInstallRecord & { pluginId: string };
|
||||
|
||||
const CLAWHUB_TRUST_INSTALL_RECORD_FIELDS = [
|
||||
"clawhubTrustDisposition",
|
||||
"clawhubTrustScanStatus",
|
||||
"clawhubTrustModerationState",
|
||||
"clawhubTrustReasons",
|
||||
"clawhubTrustPending",
|
||||
"clawhubTrustStale",
|
||||
"clawhubTrustCheckedAt",
|
||||
"clawhubTrustAcknowledgedAt",
|
||||
] as const satisfies readonly (keyof PluginInstallRecord)[];
|
||||
|
||||
/** Builds install record fields from resolved npm package metadata. */
|
||||
export function buildNpmResolutionInstallFields(
|
||||
resolution?: NpmSpecResolution,
|
||||
@@ -45,15 +34,13 @@ export function resolveNpmInstallRecordSpec(params: {
|
||||
return resolvedSpec;
|
||||
}
|
||||
|
||||
/** Records or updates a plugin install record in OpenClaw config. */
|
||||
/** Replaces a plugin install record with the authoritative completed install. */
|
||||
export function recordPluginInstall(
|
||||
cfg: OpenClawConfig,
|
||||
update: PluginInstallUpdate,
|
||||
): OpenClawConfig {
|
||||
const { pluginId, ...record } = update;
|
||||
const previous = clearStaleInstallRecordFields(cfg.plugins?.installs?.[pluginId]);
|
||||
const nextRecord = {
|
||||
...previous,
|
||||
...record,
|
||||
installedAt: record.installedAt ?? new Date().toISOString(),
|
||||
};
|
||||
@@ -70,14 +57,3 @@ export function recordPluginInstall(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function clearStaleInstallRecordFields(record: PluginInstallRecord | undefined) {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const next: PluginInstallRecord = { ...record };
|
||||
for (const field of CLAWHUB_TRUST_INSTALL_RECORD_FIELDS) {
|
||||
delete next[field];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,33 @@ function resolveMsteamsClawHubTrust(overrides: Partial<PluginInstallRecord> = {}
|
||||
return registry.plugins[0]?.trustedOfficialInstall;
|
||||
}
|
||||
|
||||
function resolveDiffsNpmTrust(overrides: Partial<PluginInstallRecord> = {}) {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, { id: "diffs", configSchema: { type: "object" } });
|
||||
const registry = loadPluginManifestRegistry({
|
||||
installRecords: {
|
||||
diffs: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/diffs",
|
||||
installPath: dir,
|
||||
resolvedName: "@openclaw/diffs",
|
||||
resolvedVersion: "2026.7.16",
|
||||
resolvedSpec: "@openclaw/diffs@2026.7.16",
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "diffs",
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diffs",
|
||||
origin: "global",
|
||||
}),
|
||||
],
|
||||
});
|
||||
return registry.plugins[0]?.trustedOfficialInstall;
|
||||
}
|
||||
|
||||
function loadRegistry(candidates: PluginCandidate[]) {
|
||||
return loadPluginManifestRegistry({
|
||||
candidates,
|
||||
@@ -744,32 +771,34 @@ describe("loadPluginManifestRegistry", () => {
|
||||
expect(registry.plugins[0]?.origin).toBe("global");
|
||||
});
|
||||
|
||||
it("marks official installed npm globals as trusted official installs", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, { id: "diagnostics-prometheus", configSchema: { type: "object" } });
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
installRecords: {
|
||||
"diagnostics-prometheus": {
|
||||
source: "npm",
|
||||
installPath: dir,
|
||||
resolvedName: "@openclaw/diagnostics-prometheus",
|
||||
resolvedVersion: "2026.5.3",
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
createPluginCandidate({
|
||||
idHint: "diagnostics-prometheus",
|
||||
rootDir: dir,
|
||||
packageName: "@openclaw/diagnostics-prometheus",
|
||||
origin: "global",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(registry.plugins[0]?.trustedOfficialInstall).toBe(true);
|
||||
it("marks official registry npm installs as trusted", () => {
|
||||
expect(resolveDiffsNpmTrust()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "npm-pack archive metadata",
|
||||
overrides: {
|
||||
sourcePath: "/tmp/diffs.tgz",
|
||||
artifactKind: "npm-pack",
|
||||
artifactFormat: "tgz",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "local source path metadata",
|
||||
overrides: { sourcePath: "/tmp/diffs.tgz" },
|
||||
},
|
||||
{
|
||||
name: "linked local path",
|
||||
overrides: { source: "path", sourcePath: "/tmp/diffs" },
|
||||
},
|
||||
] satisfies Array<{ name: string; overrides: Partial<PluginInstallRecord> }>)(
|
||||
"does not trust official package identity from $name",
|
||||
({ overrides }) => {
|
||||
expect(resolveDiffsNpmTrust(overrides)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "complete records", overrides: {} },
|
||||
{
|
||||
|
||||
@@ -859,8 +859,12 @@ function isTrustedOfficialPluginInstall(params: {
|
||||
record: installRecord,
|
||||
})
|
||||
: undefined;
|
||||
// Local npm-pack archives also persist source="npm". Only registry installs
|
||||
// may inherit catalog trust; local artifacts and source links stay untrusted.
|
||||
if (
|
||||
installRecord.source === "npm" &&
|
||||
installRecord.artifactKind === undefined &&
|
||||
installRecord.sourcePath === undefined &&
|
||||
officialInstall?.npmSpec === packageName &&
|
||||
[
|
||||
installRecord.resolvedName,
|
||||
|
||||
@@ -2,6 +2,11 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
|
||||
import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js";
|
||||
import { createChannelIngressQueue } from "../channels/message/ingress-queue.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import {
|
||||
createPluginBlobStore,
|
||||
type OpenBlobStoreOptions,
|
||||
type PluginBlobStore,
|
||||
} from "../plugin-state/plugin-blob-store.js";
|
||||
import {
|
||||
createPluginStateKeyedStore,
|
||||
createPluginStateSyncKeyedStore,
|
||||
@@ -462,32 +467,38 @@ export function createPluginRuntimeResolver(state: PluginRegistryState) {
|
||||
};
|
||||
if (prop === "state") {
|
||||
const baseState = getRuntimeProperty();
|
||||
const assertPluginStateAllowed = () => {
|
||||
const assertPluginStateAllowed = (methodName: "openBlobStore" | "openKeyedStore") => {
|
||||
const record =
|
||||
pluginRuntimeRecordById.get(pluginId) ??
|
||||
registry.plugins.find((entry) => entry.id === pluginId);
|
||||
if (record?.origin !== "bundled" && record?.trustedOfficialInstall !== true) {
|
||||
throw new Error(
|
||||
"openKeyedStore is only available for trusted plugins in this release.",
|
||||
`${methodName} is only available for trusted plugins in this release.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
...baseState,
|
||||
openBlobStore: <TMetadata>(
|
||||
options: OpenBlobStoreOptions,
|
||||
): PluginBlobStore<TMetadata> => {
|
||||
assertPluginStateAllowed("openBlobStore");
|
||||
return createPluginBlobStore<TMetadata>(pluginId, options);
|
||||
},
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions): PluginStateKeyedStore<T> => {
|
||||
assertPluginStateAllowed();
|
||||
assertPluginStateAllowed("openKeyedStore");
|
||||
return createPluginStateKeyedStore<T>(pluginId, options);
|
||||
},
|
||||
openSyncKeyedStore: <T>(
|
||||
options: OpenKeyedStoreOptions,
|
||||
): PluginStateSyncKeyedStore<T> => {
|
||||
assertPluginStateAllowed();
|
||||
assertPluginStateAllowed("openKeyedStore");
|
||||
return createPluginStateSyncKeyedStore<T>(pluginId, options);
|
||||
},
|
||||
openChannelIngressQueue: <TPayload, TMetadata = unknown, TCompletedMetadata = unknown>(
|
||||
options?: Omit<Parameters<typeof createChannelIngressQueue>[0], "channelId">,
|
||||
) => {
|
||||
assertPluginStateAllowed();
|
||||
assertPluginStateAllowed("openKeyedStore");
|
||||
const stateDir = options?.stateDir ?? baseState.resolveStateDir();
|
||||
return createChannelIngressQueue<TPayload, TMetadata, TCompletedMetadata>({
|
||||
...options,
|
||||
|
||||
@@ -333,6 +333,9 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
|
||||
logging: createRuntimeLogging(),
|
||||
state: {
|
||||
resolveStateDir,
|
||||
openBlobStore: () => {
|
||||
throw new Error("openBlobStore is only available through the plugin runtime proxy.");
|
||||
},
|
||||
openKeyedStore: () => {
|
||||
throw new Error("openKeyedStore is only available through the plugin runtime proxy.");
|
||||
},
|
||||
|
||||
@@ -421,6 +421,9 @@ export type PluginRuntimeCore = {
|
||||
};
|
||||
state: {
|
||||
resolveStateDir: typeof import("../../config/paths.js").resolveStateDir;
|
||||
openBlobStore: <TMetadata>(
|
||||
options: import("../../plugin-state/plugin-blob-store.types.js").OpenBlobStoreOptions,
|
||||
) => import("../../plugin-state/plugin-blob-store.types.js").PluginBlobStore<TMetadata>;
|
||||
openKeyedStore: <T>(
|
||||
options: import("../../plugin-state/plugin-state-store.types.js").OpenKeyedStoreOptions,
|
||||
) => import("../../plugin-state/plugin-state-store.types.js").PluginStateKeyedStore<T>;
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from "./snapshot-provider.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const TRANSIENT_PLUGIN_BLOB_MARKER = `transient-plugin-blob-${"sensitive".repeat(32)}`;
|
||||
const DURABLE_PLUGIN_BLOB_MARKER = "durable-plugin-blob-control";
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const tempDir = tempDirs.make("openclaw-snapshot-repository-");
|
||||
@@ -97,6 +99,40 @@ function createGlobalDatabase(databasePath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function seedGlobalPluginBlobSnapshotFixtures(databasePath: string): void {
|
||||
const sqlite = requireNodeSqlite();
|
||||
const database = new sqlite.DatabaseSync(databasePath);
|
||||
try {
|
||||
const insertPluginBlob = database.prepare(
|
||||
`
|
||||
INSERT INTO plugin_blob_entries (
|
||||
plugin_id, namespace, entry_key, metadata_json, blob, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
insertPluginBlob.run(
|
||||
"diffs",
|
||||
"viewer-artifacts",
|
||||
"transient",
|
||||
JSON.stringify({ marker: TRANSIENT_PLUGIN_BLOB_MARKER }),
|
||||
Buffer.from(`<html>${TRANSIENT_PLUGIN_BLOB_MARKER}</html>`),
|
||||
1,
|
||||
Date.UTC(2099, 0, 1),
|
||||
);
|
||||
insertPluginBlob.run(
|
||||
"durable-plugin",
|
||||
"documents",
|
||||
"durable",
|
||||
JSON.stringify({ kind: "durable" }),
|
||||
Buffer.from(DURABLE_PLUGIN_BLOB_MARKER),
|
||||
1,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function createAgentDatabase(databasePath: string, agentId: string): void {
|
||||
const sqlite = requireNodeSqlite();
|
||||
const database = new sqlite.DatabaseSync(databasePath);
|
||||
@@ -1042,28 +1078,58 @@ describe("local SQLite snapshot repository", () => {
|
||||
await expect(fs.readFile(racedPath!, "utf8")).resolves.toBe("racer");
|
||||
});
|
||||
|
||||
it("sanitizes transient global delivery rows and enforces the global owner", async () => {
|
||||
it("sanitizes transient global rows and enforces the global owner", async () => {
|
||||
const tempDir = await createTempDir();
|
||||
const sourcePath = path.join(tempDir, "openclaw.sqlite");
|
||||
const repositoryPath = path.join(tempDir, "snapshots");
|
||||
createGlobalDatabase(sourcePath);
|
||||
seedGlobalPluginBlobSnapshotFixtures(sourcePath);
|
||||
const provider = createLocalSqliteSnapshotProvider({ repositoryPath });
|
||||
const snapshot = await provider.create({
|
||||
path: sourcePath,
|
||||
identity: { role: "global" },
|
||||
});
|
||||
const artifactPath = path.join(snapshot.ref.path, SNAPSHOT_SQLITE_FILENAME);
|
||||
expect((await fs.readFile(artifactPath)).includes("do-not-restore")).toBe(false);
|
||||
const artifactBytes = await fs.readFile(artifactPath);
|
||||
expect(artifactBytes.includes("do-not-restore")).toBe(false);
|
||||
expect(artifactBytes.includes(TRANSIENT_PLUGIN_BLOB_MARKER)).toBe(false);
|
||||
expect(artifactBytes.includes(DURABLE_PLUGIN_BLOB_MARKER)).toBe(true);
|
||||
const sqlite = requireNodeSqlite();
|
||||
const artifact = new sqlite.DatabaseSync(artifactPath, { readOnly: true });
|
||||
try {
|
||||
expect(
|
||||
artifact.prepare("SELECT COUNT(*) AS count FROM delivery_queue_entries").get(),
|
||||
).toEqual({ count: 0 });
|
||||
expect(
|
||||
artifact
|
||||
.prepare(
|
||||
"SELECT plugin_id, entry_key FROM plugin_blob_entries ORDER BY plugin_id, entry_key",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([{ plugin_id: "durable-plugin", entry_key: "durable" }]);
|
||||
} finally {
|
||||
artifact.close();
|
||||
}
|
||||
|
||||
const source = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
|
||||
try {
|
||||
expect(source.prepare("SELECT COUNT(*) AS count FROM delivery_queue_entries").get()).toEqual({
|
||||
count: 1,
|
||||
});
|
||||
expect(
|
||||
source
|
||||
.prepare(
|
||||
"SELECT plugin_id, entry_key FROM plugin_blob_entries ORDER BY plugin_id, entry_key",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ plugin_id: "diffs", entry_key: "transient" },
|
||||
{ plugin_id: "durable-plugin", entry_key: "durable" },
|
||||
]);
|
||||
} finally {
|
||||
source.close();
|
||||
}
|
||||
|
||||
const wrongRolePath = path.join(tempDir, "wrong-role.sqlite");
|
||||
createAgentDatabase(wrongRolePath, "main");
|
||||
const wrongRole = new sqlite.DatabaseSync(wrongRolePath);
|
||||
|
||||
@@ -4,7 +4,6 @@ import fs from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { z } from "zod";
|
||||
import { loadSqliteVecExtension } from "../../packages/memory-host-sdk/src/engine-storage.js";
|
||||
@@ -31,6 +30,7 @@ import { runExec } from "../process/exec.js";
|
||||
import { isValidAgentId, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { assertOpenClawAgentDatabaseForMaintenance } from "../state/openclaw-agent-db.js";
|
||||
import { assertOpenClawStateDatabaseForMaintenance } from "../state/openclaw-state-db.js";
|
||||
import { sanitizeOpenClawGlobalStateSnapshot } from "../state/openclaw-state-snapshot-sanitizer.js";
|
||||
import {
|
||||
containsAsciiControlCharacter,
|
||||
copySnapshotArtifact,
|
||||
@@ -272,7 +272,7 @@ class LocalSqliteSnapshotProvider implements SqliteSnapshotProvider {
|
||||
const result = await createVerifiedSqliteSnapshot({
|
||||
sourcePath,
|
||||
targetPath: artifactPath,
|
||||
transform: identity.role === "global" ? sanitizeGlobalStateSnapshot : undefined,
|
||||
transform: identity.role === "global" ? sanitizeOpenClawGlobalStateSnapshot : undefined,
|
||||
validate: buildDatabaseValidator(identity),
|
||||
});
|
||||
applyPrivateModeSync(artifactPath, SNAPSHOT_FILE_MODE);
|
||||
@@ -737,10 +737,6 @@ function buildManifestDatabaseValidator(
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeGlobalStateSnapshot(database: DatabaseSync): void {
|
||||
database.prepare("DELETE FROM delivery_queue_entries").run();
|
||||
}
|
||||
|
||||
function buildSnapshotId(now: Date): string {
|
||||
const timestamp = now.toISOString().replaceAll(/[:.]/g, "-");
|
||||
return `${timestamp}-${randomUUID()}`;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Removes transient runtime state from restorable global database snapshots.
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
function tableExists(database: DatabaseSync, tableName: string): boolean {
|
||||
const row = database // sqlite-allow-raw -- Offline snapshot maintenance boundary.
|
||||
.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { ok?: unknown } | undefined;
|
||||
return row?.ok === 1;
|
||||
}
|
||||
|
||||
/** Remove transient rows whose restoration would replay work or extend private-data retention. */
|
||||
export function sanitizeOpenClawGlobalStateSnapshot(database: DatabaseSync): void {
|
||||
// Archive backup can encounter an older database shape, so each optional
|
||||
// table is detected before applying the current sanitizer contract.
|
||||
if (tableExists(database, "delivery_queue_entries")) {
|
||||
database.prepare("DELETE FROM delivery_queue_entries").run(); // sqlite-allow-raw -- Offline snapshot maintenance boundary.
|
||||
}
|
||||
if (tableExists(database, "plugin_blob_entries")) {
|
||||
// A TTL marks blob data as transient. Exclude every TTL row, including one
|
||||
// that is still live, so restore cannot prolong its original retention.
|
||||
database.prepare("DELETE FROM plugin_blob_entries WHERE expires_at IS NOT NULL").run(); // sqlite-allow-raw -- Offline snapshot maintenance boundary.
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,25 @@ describe("check-database-first-legacy-stores", () => {
|
||||
expect(violations).toEqual([{ kind: "legacy store filesystem write", line: 5 }]);
|
||||
});
|
||||
|
||||
it("flags retired Diffs viewer sidecar writes", () => {
|
||||
const violations = collectDatabaseFirstLegacyStoreViolations(
|
||||
`
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
await fs.writeFile(path.join(root, id, "viewer.html"), html);
|
||||
await fs.writeFile(path.join(root, id, "meta.json"), metadata);
|
||||
await fs.writeFile(path.join(root, id, "file-meta.json"), metadata);
|
||||
`,
|
||||
"extensions/diffs/src/legacy-store.ts",
|
||||
);
|
||||
|
||||
expect(violations).toEqual([
|
||||
{ kind: "legacy store filesystem write", line: 4 },
|
||||
{ kind: "legacy store filesystem write", line: 5 },
|
||||
{ kind: "legacy store filesystem write", line: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags writes through local variables initialized from legacy store paths", () => {
|
||||
const violations = collectDatabaseFirstLegacyStoreViolations(
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user