fix(capture): synthesize manifest plugin config
This commit is contained in:
parent
9f45c8aeb6
commit
a58e0785d5
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
126
src/capture-config.js
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user