Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed

- Mock the current plugin blob-store runtime so Diffs and other trusted plugins can be captured without persistent state.
- Parse OpenClaw hook names after their source declaration became private and added a TypeScript `satisfies` constraint.
- Run synthetic gateway lifecycle hooks around ordinary probes while preserving capture indexes and report order, so `gateway_stop` teardown cannot invalidate later compatibility checks.
- Link isolated plugin workspaces to the OpenClaw checkout without npm normalizing duplicated dependency and peer declarations back to registry ranges.
Expand Down
89 changes: 89 additions & 0 deletions src/capture-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ function registrationReturnValue(name, args, context) {

function createRuntimeContext(options) {
const runtime = options.runtime ?? {};
const blobStores = new Map();
const syncKeyedStores = new Map();
return {
...runtime,
Expand All @@ -204,6 +205,15 @@ function createRuntimeContext(options) {
tts: runtime.tts ?? {},
state: {
resolveStateDir: () => options.stateDir ?? process.cwd(),
openBlobStore(storeOptions) {
const existing = blobStores.get(storeOptions.namespace);
if (existing) {
return existing;
}
const store = createBlobStoreContext(storeOptions);
blobStores.set(storeOptions.namespace, store);
return store;
},
openSyncKeyedStore({ namespace }) {
const existing = syncKeyedStores.get(namespace);
if (existing) {
Expand All @@ -218,6 +228,85 @@ function createRuntimeContext(options) {
};
}

function createBlobStoreContext(options) {
const values = new Map();
const entryInfo = (key, entry) => ({
key,
metadata: entry.metadata,
sizeBytes: entry.bytes.byteLength,
createdAt: entry.createdAt,
...(entry.expiresAt === undefined ? {} : { expiresAt: entry.expiresAt }),
});
const read = (key) => {
const entry = values.get(key);
if (!entry) {
return undefined;
}
if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) {
return undefined;
}
return { ...entryInfo(key, entry), bytes: Uint8Array.from(entry.bytes) };
};
const register = async (key, bytes, metadata, registerOptions) => {
const createdAt = Date.now();
const ttlMs = registerOptions?.ttlMs ?? options.defaultTtlMs;
values.set(key, {
bytes: Uint8Array.from(bytes),
Comment on lines +253 to +254

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce blob store limits in the mock

When a plugin creates blobs during registration or synthetic probes, this mock stores every entry unconditionally, so blobs larger than maxBytesPerEntry or writes that exceed maxEntries/maxBytesPerNamespace still succeed even though the real OpenClaw blob store rejects or evicts according to those options. That can make runtime capture report a trusted plugin as compatible when the same registration path would fail under the host runtime; please simulate the configured byte/row limits before inserting.

Useful? React with 👍 / 👎.

metadata,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clone blob metadata at store boundaries

If a plugin mutates the metadata object after register()/registerIfAbsent() or passes metadata that JSON persistence would reject, the mock keeps that original object reference and later lookup(), entries(), or deleteExpired() observes the mutated/invalid value. The real blob store persists metadata as JSON and parses it on reads, so metadata is copied and validated at the API boundary; this divergence can let capture pass for plugins that would fail or clean up the wrong artifact under OpenClaw.

Useful? React with 👍 / 👎.

createdAt,
...(ttlMs === undefined ? {} : { expiresAt: createdAt + ttlMs }),
});
};
return {
register,
async registerIfAbsent(key, bytes, metadata, registerOptions) {
if (values.has(key)) {
return false;
}
await register(key, bytes, metadata, registerOptions);
return true;
},
async lookup(key) {
return read(key);
},
async entries() {
return [...values.keys()].flatMap((key) => {
const entry = read(key);
if (!entry) {
return [];
}
const { bytes: _bytes, ...info } = entry;
return [info];
});
},
async delete(key) {
return values.delete(key);
},
async deleteExpiredKey(key) {
const entry = values.get(key);
if (!entry || entry.expiresAt === undefined || entry.expiresAt > Date.now()) {
return undefined;
}
values.delete(key);
return entryInfo(key, entry);
},
async deleteExpired() {
const expired = [];
for (const [key, entry] of values) {
if (entry.expiresAt === undefined || entry.expiresAt > Date.now()) {
continue;
}
values.delete(key);
expired.push(entryInfo(key, entry));
}
return expired;
},
async clear() {
values.clear();
},
};
}

function createSyncKeyedStoreContext() {
const values = new Map();
return {
Expand Down
45 changes: 45 additions & 0 deletions test/capture-api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,16 @@ test("capture API exposes mock context helpers", async () => {
namespace: "capture-test",
maxEntries: 10,
});
const blobStore = api.runtime.state.openBlobStore({
namespace: "capture-blobs",
maxEntries: 10,
maxBytesPerEntry: 1_024,
maxBytesPerNamespace: 4_096,
});
const sourceBytes = Buffer.from([1, 2, 3]);
keyedStore.register("key", { value: 2 });
assert.equal(await blobStore.registerIfAbsent("artifact", sourceBytes, { kind: "diff" }), true);
sourceBytes[0] = 9;

assert.equal(await api.secrets.get("token"), "redacted");
assert.equal(await api.secrets.resolve("secret:token"), "redacted");
Expand All @@ -98,6 +107,42 @@ test("capture API exposes mock context helpers", async () => {
assert.equal(api.paths.dataDir, ".plugin-inspector/data");
assert.equal(api.resolvePath("state"), "/fixture/state");
assert.deepEqual(keyedStore.lookup("key"), { value: 2 });
const firstLookup = await blobStore.lookup("artifact");
assert.deepEqual([...(firstLookup?.bytes ?? [])], [1, 2, 3]);
firstLookup.bytes[1] = 9;
assert.deepEqual([...((await blobStore.lookup("artifact"))?.bytes ?? [])], [1, 2, 3]);
assert.deepEqual((await blobStore.entries()).map(({ key, metadata, sizeBytes }) => ({ key, metadata, sizeBytes })), [
{ key: "artifact", metadata: { kind: "diff" }, sizeBytes: 3 },
]);
assert.equal(await blobStore.registerIfAbsent("artifact", new Uint8Array([4]), { kind: "other" }), false);
const originalDateNow = Date.now;
let now = 1_000;
Date.now = () => now;
try {
assert.equal(
await blobStore.registerIfAbsent("expiring", new Uint8Array([5]), { kind: "old" }, { ttlMs: 10 }),
true,
);
now = 1_011;
assert.equal(await blobStore.lookup("expiring"), undefined);
assert.equal(
await blobStore.registerIfAbsent("expiring", new Uint8Array([6]), { kind: "new" }),
false,
);
assert.deepEqual(await blobStore.deleteExpiredKey("expiring"), {
key: "expiring",
metadata: { kind: "old" },
sizeBytes: 1,
createdAt: 1_000,
expiresAt: 1_010,
});
assert.equal(
await blobStore.registerIfAbsent("expiring", new Uint8Array([6]), { kind: "new" }),
true,
);
} finally {
Date.now = originalDateNow;
}
assert.equal(keyedStore.update("key", () => undefined), false);
assert.deepEqual(keyedStore.lookup("key"), { value: 2 });
assert.equal(
Expand Down
2 changes: 2 additions & 0 deletions test/runtime-capture-report.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ test("runtime capture supports TypeScript entrypoints, SDK subpaths, external mo
"reexportedSdkHelper?.();",
"export default definePluginEntry((api) => {",
" api.runtime.state.resolveStateDir();",
" const blobStore = api.runtime.state.openBlobStore({ namespace: 'artifacts', maxEntries: 10, maxBytesPerEntry: 1024, maxBytesPerNamespace: 4096 });",
" void blobStore.lookup('viewer');",
" const stateStore = api.runtime.state.openSyncKeyedStore({ namespace: 'weather', maxEntries: 10 });",
" stateStore.register('forecast', { value: 'sunny' });",
" stateStore.lookup('forecast');",
Expand Down
Loading