From a58e0785d5fea8ec13f7b795609802b82a3725e7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 00:11:24 -0700 Subject: [PATCH] fix(capture): synthesize manifest plugin config --- CHANGELOG.md | 6 ++ package.json | 2 +- src/capture-api.js | 1 + src/capture-config.js | 126 ++++++++++++++++++++++++++++ src/inspector.js | 10 ++- src/mock-sdk-capture-runner.js | 10 ++- test/capture-api.test.js | 2 + test/runtime-capture-report.test.js | 78 +++++++++++++++++ 8 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 src/capture-config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a148ec..bcf9f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 1f1828e..9b3734e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/capture-api.js b/src/capture-api.js index cf1626b..71fba3e 100644 --- a/src/capture-api.js +++ b/src/capture-api.js @@ -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), diff --git a/src/capture-config.js b/src/capture-config.js new file mode 100644 index 0000000..1f2acc2 --- /dev/null +++ b/src/capture-config.js @@ -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); +} diff --git a/src/inspector.js b/src/inspector.js index 25d0d78..90d6aa6 100644 --- a/src/inspector.js +++ b/src/inspector.js @@ -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; diff --git a/src/mock-sdk-capture-runner.js b/src/mock-sdk-capture-runner.js index 024ede5..c480864 100644 --- a/src/mock-sdk-capture-runner.js +++ b/src/mock-sdk-capture-runner.js @@ -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); diff --git a/test/capture-api.test.js b/test/capture-api.test.js index 175d5f9..879e302 100644 --- a/test/capture-api.test.js +++ b/test/capture-api.test.js @@ -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", () => { diff --git a/test/runtime-capture-report.test.js b/test/runtime-capture-report.test.js index 825dc41..519619e 100644 --- a/test/runtime-capture-report.test.js +++ b/test/runtime-capture-report.test.js @@ -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 });