fix(capture): synthesize manifest plugin config

This commit is contained in:
Vincent Koc 2026-05-03 00:11:24 -07:00
parent 9f45c8aeb6
commit a58e0785d5
No known key found for this signature in database
8 changed files with 229 additions and 6 deletions

View File

@ -2,6 +2,12 @@
## Unreleased
## 0.3.8 - 2026-05-03
### Fixed
- Synthesize manifest config for isolated runtime capture so configured hooks can be observed without credentials.
## 0.3.7 - 2026-05-03
### Changed

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/plugin-inspector",
"version": "0.3.7",
"version": "0.3.8",
"private": false,
"description": "Offline compatibility inspector for OpenClaw plugins.",
"type": "module",

View File

@ -148,6 +148,7 @@ export function createCaptureContext(options = {}) {
config: options.config ?? {},
logger: options.logger ?? console,
pluginConfig: options.pluginConfig ?? {},
resolvePath: options.resolvePath ?? ((value) => value),
runtime: options.runtime ?? createRuntimeContext(options),
secrets: options.secrets ?? createSecretContext(options),
store: options.store ?? createStoreContext(options),

126
src/capture-config.js Normal file
View File

@ -0,0 +1,126 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
export async function captureApiOptionsForPlugin(apiOptions = {}, options = {}) {
if (apiOptions.pluginConfig !== undefined || !options.pluginRoot) {
return apiOptions;
}
const pluginConfig = await readSamplePluginConfig(options.pluginRoot);
if (pluginConfig === undefined) {
return apiOptions;
}
return {
...apiOptions,
pluginConfig,
};
}
async function readSamplePluginConfig(pluginRoot) {
const manifestPath = path.join(pluginRoot, "openclaw.plugin.json");
let manifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
} catch {
return undefined;
}
const sample = sampleJsonSchema(manifest.configSchema, { key: "config" });
return isPlainObject(sample) && Object.keys(sample).length > 0 ? sample : undefined;
}
function sampleJsonSchema(schema, context = {}) {
if (!isPlainObject(schema)) {
return undefined;
}
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
return schema.enum[0];
}
if (Object.prototype.hasOwnProperty.call(schema, "const")) {
return schema.const;
}
if (Object.prototype.hasOwnProperty.call(schema, "default")) {
return schema.default;
}
const type = Array.isArray(schema.type) ? schema.type.find((item) => item !== "null") : schema.type;
if (type === "object" || schema.properties) {
return sampleObjectSchema(schema);
}
if (type === "array") {
return [];
}
if (type === "boolean") {
return false;
}
if (type === "number" || type === "integer") {
return typeof schema.minimum === "number" ? schema.minimum : 1;
}
if (type === "string" || !type) {
return sampleString(context.key);
}
return undefined;
}
function sampleObjectSchema(schema) {
const properties = isPlainObject(schema.properties) ? schema.properties : {};
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const output = {};
for (const key of Object.keys(properties)) {
if (required.has(key)) {
const value = sampleJsonSchema(properties[key], { key });
if (value !== undefined) {
output[key] = value;
}
}
}
for (const key of Object.keys(properties)) {
if (properties[key]?.type === "boolean") {
output[key] = false;
}
}
if (Object.keys(output).length === 0 && Number(schema.minProperties ?? 0) > 0) {
const key = preferredSamplePropertyKey(properties);
if (key) {
const value = sampleJsonSchema(properties[key], { key });
if (value !== undefined) {
output[key] = value;
}
}
}
return output;
}
function preferredSamplePropertyKey(properties) {
for (const key of ["provider", "model", "apiKey", "id", "name", ...Object.keys(properties)]) {
if (Object.prototype.hasOwnProperty.call(properties, key)) {
return key;
}
}
return null;
}
function sampleString(key = "") {
if (key === "provider") {
return "openai";
}
if (key === "model") {
return "text-embedding-3-small";
}
if (key === "apiKey") {
return "fixture-api-key";
}
if (key === "dbPath") {
return ".plugin-inspector/state/lancedb";
}
return "fixture";
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { createCaptureApi } from "./capture-api.js";
import { captureApiOptionsForPlugin } from "./capture-config.js";
import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js";
import { buildCompatibilityFixtureReport } from "./fixture-summary.js";
import { readOpenClawTargetSurface } from "./openclaw-target.js";
@ -188,7 +189,12 @@ export async function captureEntrypoint(entrypoint, options = {}) {
};
}
const api = createCaptureApi(options.apiOptions);
const apiOptions = await captureApiOptionsForPlugin(options.apiOptions, {
pluginRoot: options.pluginRoot
? path.resolve(options.cwd ?? process.cwd(), options.pluginRoot)
: path.dirname(resolvedEntrypoint),
});
const api = createCaptureApi(apiOptions);
try {
await register(api);
} catch (error) {
@ -199,7 +205,7 @@ export async function captureEntrypoint(entrypoint, options = {}) {
entrypoint: resolvedEntrypoint,
captured: api.getCapturedContracts(),
};
if (options.apiOptions?.retainHandlers === true) {
if (apiOptions?.retainHandlers === true) {
result.retained = api.getRetainedContracts();
}
return result;

View File

@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createCaptureApi } from "./capture-api.js";
import { captureApiOptionsForPlugin } from "./capture-config.js";
import { createMockSdkPackage } from "./sdk-mock.js";
const options = JSON.parse(process.argv[2] ?? "{}");
@ -30,7 +31,7 @@ async function run(options) {
cleanupTempDirOnExit(workspace);
const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
register(pathToFileURL(loaderPath));
return await captureLinkedEntrypoint(entrypoint, options);
return await captureLinkedEntrypoint(entrypoint, { ...options, pluginRoot });
}
function cleanupTempDirOnExit(dir) {
@ -65,7 +66,10 @@ async function captureLinkedEntrypoint(entrypoint, options) {
);
}
const api = createCaptureApi(options.apiOptions);
const apiOptions = await captureApiOptionsForPlugin(options.apiOptions, {
pluginRoot: options.pluginRoot,
});
const api = createCaptureApi(apiOptions);
try {
await register(api);
} catch (error) {
@ -80,7 +84,7 @@ async function captureLinkedEntrypoint(entrypoint, options) {
mockSdk: true,
captured: api.getCapturedContracts(),
};
if (options.apiOptions?.retainHandlers === true) {
if (apiOptions?.retainHandlers === true) {
result.retained = api.getRetainedContracts();
}
return withProcessOutput(result, outputCapture);

View File

@ -77,6 +77,7 @@ test("capture API accepts custom registrar return profiles", () => {
test("capture API exposes mock context helpers", async () => {
const api = createCaptureApi({
resolvePath: (value) => `/fixture/${value}`,
secretValues: {
token: "redacted",
},
@ -90,6 +91,7 @@ test("capture API exposes mock context helpers", async () => {
assert.deepEqual(await api.store.list(), ["key"]);
assert.equal(api.agent.id, "plugin-inspector-agent");
assert.equal(api.paths.dataDir, ".plugin-inspector/data");
assert.equal(api.resolvePath("state"), "/fixture/state");
});
test("capture API can retain handlers for probes", () => {

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { test } from "node:test";
import {
buildRuntimeCaptureReport,
captureEntrypoint,
inspectCompatibilityFixtureSet,
loadPluginRootConfig,
writeRuntimeCaptureReport,
@ -274,6 +275,83 @@ test("runtime capture supports TypeScript entrypoints, SDK subpaths, external mo
assert.match(captureReport.results[0].processOutput.stdout, /late plugin noise/);
});
test("runtime capture synthesizes manifest config before plugin registration", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-runtime-config-"));
await mkdir(path.join(rootDir, "src"), { recursive: true });
await writeFile(
path.join(rootDir, "package.json"),
`${JSON.stringify(
{
name: "openclaw-configured-memory",
version: "1.0.0",
type: "module",
openclaw: {
extensions: ["src/index.ts"],
compat: { pluginApi: "^1.0.0" },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
path.join(rootDir, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: "configured-memory",
configSchema: {
type: "object",
properties: {
embedding: {
type: "object",
minProperties: 1,
properties: {
provider: { type: "string" },
model: { type: "string" },
},
},
autoCapture: { type: "boolean" },
autoRecall: { type: "boolean" },
},
required: ["embedding"],
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
path.join(rootDir, "src", "index.ts"),
[
'import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";',
"export default definePluginEntry({",
" register(api) {",
" if (!api.pluginConfig?.embedding) {",
" api.registerService({ id: 'configured-memory-disabled', start() {} });",
" return;",
" }",
" api.on('agent_end', () => undefined);",
" },",
"});",
].join("\n"),
"utf8",
);
const result = await captureEntrypoint("src/index.ts", {
cwd: rootDir,
pluginRoot: rootDir,
mockSdk: true,
});
assert.equal(result.status, "captured");
assert.deepEqual(
result.captured.map((item) => `${item.kind}:${item.name}`),
["hook:agent_end"],
);
});
test("runtime capture supports namespace imports from mocked externals", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-runtime-zod-namespace-"));
await mkdir(path.join(rootDir, "src"), { recursive: true });