@openclaw/plugin-inspector 0.3.24 → 0.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.26 - 2026-09-20
6
+
7
+ ### Fixed
8
+
9
+ - Recognize OpenClaw's declared private-local Plugin SDK subpaths and reserved bundled-plugin imports when inspecting bundled `extensions/*` fixtures, while continuing to report those imports for external plugins and genuinely missing SDK aliases.
10
+ - Treat active OpenClaw conversation-access compat records with present contract tests as target-owned proof instead of repeatedly emitting a P1 Inspector probe backlog advisory.
11
+ - Record explicit method-scoped Gateway probe prerequisites before calling handlers that require unavailable host state or live credentials; preserve actual response failures for admitted probes.
12
+ - Preserve absent optional strings in SDK mocks and supply a stable runtime config snapshot to Gateway probes instead of inventing configured values or missing host accessors.
13
+ - Generate SDK mocks for literal dynamic imports in retained handlers, including their named exports, while excluding TypeScript import types and keeping source inspection aligned with runtime capture.
14
+ - Honor the OpenClaw lazy-runtime SDK contract in generated mocks, preserving deferred module loading, shared promise caches, explicit cache clearing, and rejected imports instead of returning callable placeholders.
15
+ - Reject invalid batch concurrency instead of reporting success without inspecting any plugins, and preserve relative plugin paths in retained reports so similar directory names cannot overwrite each other's results.
16
+ - Preserve error messages from the OpenClaw `error-runtime` SDK subpath in synthetic probes, including lazy CommonJS imports, so Gateway rejections report their actual cause and remain failures.
17
+ - Keep tool-hook and conversation-privacy contract gaps open when runtime artifacts only capture hook registration. Preserve registration coverage and semantic contract probes.
18
+ - Read OpenClaw compatibility records from the explicitly imported `registry-records.ts` data module, avoiding false missing-record findings after the registry split. Preserve inline registries and report missing delegated data as an error.
19
+
20
+ ## 0.3.25 - 2026-09-09
21
+
22
+ ### Fixed
23
+
24
+ - Invoke each synthetic Gateway method once, including registrations with options, and validate its first emitted response's JSON wire representation instead of treating any nonthrowing callback as successful. Preserve explicit response authority, returned-payload fallback, deferred replies within the existing deadline, and accepted-only initial responses.
25
+ - Bound synthetic callback waits and cancellation, stop dependent probes after a timeout, and supervise CLI capture plus retained callbacks in one child. Validate report shape and counts before delivery while preserving complete failed-row reports, bounded plugin output, and in-process callback identity. Thanks @SebTardif.
26
+ - Run `registerService` start, stop, and dispose probes serially so teardown cannot overlap startup.
27
+ - Bound real-SDK CLI capture in an owned child, including stalled imports, busy registration, and retained timers. Give in-process capture a finite 30-second default deadline while preserving caller runtime and handler identity; arbitrary in-process JavaScript cannot be forcibly canceled. Thanks @SebTardif.
28
+ - Capture and synthetically probe compiled CommonJS plugins with generated SDK mocks, including lazy `require()` calls, and discover their SDK source references. CommonJS mocking requires Node.js 22.15+ synchronous module hooks; the package engine and existing ESM/TypeScript capture remain unchanged.
29
+ - Bound mock-SDK capture and profile child lifetimes, output, and process sampling; clean owned POSIX descendants through stdio close and keep timeout/cancellation outcomes unsuccessful. Flush complete capture JSON before exiting despite retained plugin timers. Thanks @SebTardif.
30
+ - Profile the default import-loop capture runner directly so its timeout also owns plugin execution. Validate fresh, bounded capture artifacts; RSS/CPU and wall-time measurements now exclude the intermediate CLI wrapper and are not directly comparable with historical profiles.
31
+ - Bound OpenClaw npm metadata and tarball downloads with a deadline through response-body reads, reject oversized responses, and release failed downloads. Resolve `latest` and `beta` through the small npm dist-tags endpoint before fetching exact-version metadata, keeping the 16 MiB metadata limit usable.
32
+ - Capture plugins that bind `api.runtime.modelAuth` during registration with credential-free defaults; auth acquisition remains an explicit synthetic failure.
33
+ - Report absent build output and missing entrypoints before SDK alias blockers in cold-import readiness, preserving build-required totals and all remediation evidence.
34
+ - Recognize board widget content kinds, memory prompt preparation, transcript source providers, worker providers, and MCP server connection resolvers as metadata-only synthetic probes without invoking runtime callbacks.
35
+
5
36
  ## 0.3.24 - 2026-08-31
6
37
 
7
38
  ### Fixed
package/README.md CHANGED
@@ -200,6 +200,13 @@ Common options:
200
200
  | `--junit [path]` | Write JUnit XML from `check` or `inspect`; `ci` enables this by default. |
201
201
  | `--no-sarif` / `--no-junit` | Disable default `ci` outputs. |
202
202
 
203
+ For `batch`, `--concurrency <n>` must be a finite number (default `4`, rounded
204
+ and clamped to `1`–`32` workers). Invalid or missing values fail before inspection
205
+ or report writes. `--keep-plugin-reports` retains individual reports under
206
+ `<out>/plugins/<relative-plugin-path>/`, preserving the corpus directory layout
207
+ and names so plugins such as `a/b`, `a-b`, and `a b` cannot overwrite each other's
208
+ reports. If the corpus root is itself a plugin, its reports go in `<out>/plugins/`.
209
+
203
210
  Run the built-in help for the exact CLI surface:
204
211
 
205
212
  ```bash
@@ -232,6 +239,20 @@ That keeps compatibility CI offline and credential-free. It does not call live
232
239
  services, launch OpenClaw, run provider SDKs, or emulate service lifecycle side
233
240
  effects.
234
241
 
242
+ CommonJS SDK mocking, including compiled `.cjs` entrypoints and lazy `require()`
243
+ calls in synthetic handlers, requires Node.js 22.15 or newer with
244
+ `module.registerHooks()`. On older Node versions, upgrade Node.js or use an
245
+ ESM/TypeScript entrypoint. This capability requirement does not change the
246
+ package's Node.js `>=22` engine range or gate existing ESM/TypeScript capture.
247
+ Static inspection also discovers literal CommonJS SDK `require()` references.
248
+
249
+ The default capture `api.runtime.modelAuth` passes synthetic provider IDs through
250
+ unchanged, returns fresh empty auth stores and profile lists, and reports no
251
+ configured API keys. Auth acquisition rejects with a mock-auth-unavailable error;
252
+ it never looks up host credentials. This supports registration and no-auth
253
+ callbacks, not provider alias validation or authenticated execution. An explicitly
254
+ supplied runtime is preserved unchanged, including an empty runtime.
255
+
235
256
  Synthetic probes classify widget presenters as metadata-only. They record the
236
257
  registration without calling its match, availability, or presentation callbacks,
237
258
  including when channel, provider, or lifecycle execution is enabled.
@@ -239,6 +260,20 @@ including when channel, provider, or lifecycle execution is enabled.
239
260
  Use `--real-sdk` only when the plugin workspace already has real SDK
240
261
  dependencies installed and you intentionally want that path.
241
262
 
263
+ Real-SDK CLI capture runs in an owned child, including runtime capture enabled
264
+ by flags or plugin config. The parent bounds imports and registration, then
265
+ cleans up retained plugin timers after the child flushes its complete result.
266
+ It uses installed SDK dependencies without loading the mock SDK.
267
+
268
+ The real-SDK programmatic API stays in-process to preserve supplied runtime
269
+ objects and retained handler identity. Its 30-second default deadline reports
270
+ `capture-timeout`, stops later inspector-owned phases, and aborts supported
271
+ setup reads. Caller `signal` cancellation also stops later phases. Neither
272
+ mechanism can preempt a synchronous JavaScript loop, unload an import, stop
273
+ arbitrary plugin side effects, or clear plugin-owned timers in the caller's
274
+ process. Only owned-child capture provides that process-lifetime boundary.
275
+ Override the API budget with `timeoutMs` or `PLUGIN_INSPECTOR_CAPTURE_TIMEOUT_MS`.
276
+
242
277
  Runtime capture writes:
243
278
 
244
279
  - `reports/plugin-inspector-runtime-capture.json`
@@ -250,6 +285,110 @@ Capture one entrypoint directly:
250
285
  plugin-inspector capture ./dist/index.js --mock-sdk --allow-execute
251
286
  ```
252
287
 
288
+ CLI capture, mock-SDK API capture, and import-loop/runtime profiles give each
289
+ child a 30-second budget. Capture reports `capture-timeout`; timed-out profile samples
290
+ always have a nonzero `exitCode`, even if a SIGTERM handler exits zero.
291
+ Pass an `AbortSignal` as `signal` to cancel owned-child work. Cancellation is
292
+ never a successful capture or profile sample.
293
+
294
+ On POSIX, each child owns a separate process group. Completion waits for
295
+ stdout/stderr to close and cleans descendants, including after a successful
296
+ leader exit. Shutdown sends SIGTERM, then SIGKILL after a 1-second grace
297
+ period. A further 1-second close deadline fails the operation if pipes remain
298
+ open. Descendants that deliberately leave the group are not contained;
299
+ this is lifecycle supervision, not a sandbox. Windows retains direct-child
300
+ termination and the bounded close deadline, not POSIX group cleanup.
301
+
302
+ The API options `timeoutMs`, `killGraceMs`, and `maxOutputBytes` take precedence
303
+ over `PLUGIN_INSPECTOR_CAPTURE_TIMEOUT_MS`, `PLUGIN_INSPECTOR_CAPTURE_KILL_GRACE_MS`,
304
+ and `PLUGIN_INSPECTOR_CAPTURE_MAX_OUTPUT_BYTES` for owned-child capture. Profiles use
305
+ the corresponding `PLUGIN_INSPECTOR_PROFILE_*` variables. Values must be finite
306
+ positive numbers (zero does not disable limits); invalid values fall through
307
+ to the environment, then defaults. Durations/byte limits cannot exceed
308
+ 2,147,483,647; grace cannot exceed 30,000 ms.
309
+
310
+ Each profiled stdout/stderr stream retains at most 1 MiB by default while
311
+ continuing to drain output. Owned-child capture retains at most 10 MiB per pipe and
312
+ fails if its JSON response is truncated; intercepted plugin stdout/stderr
313
+ inside that response retains at most 1 MiB each. The optional `ps` sampler
314
+ also has bounded output, execution, and cleanup.
315
+
316
+ Default import-loop profiles launch the mock capture runner directly under one
317
+ profile budget, for both baseline and plugin samples. Their JSON artifacts
318
+ retain capture's 10 MiB default limit (`PLUGIN_INSPECTOR_CAPTURE_MAX_OUTPUT_BYTES`,
319
+ or an explicit `maxOutputBytes` override), separately from the profile's
320
+ 1 MiB stdout/stderr limits. Artifacts are accepted only after a successful
321
+ current capture. RSS and CPU now measure the actual runner, and wall time no
322
+ longer includes intermediate CLI startup. Historical measurements from the
323
+ CLI-wrapper route are not directly comparable. Custom `captureCommand` and
324
+ `captureScript` launch contracts are unchanged; custom detached groups are
325
+ outside the owned process group.
326
+
327
+ These limits apply to owned child processes only. The public in-process
328
+ `captureEntrypoint` path preserves retained handler identity and does not
329
+ claim to cancel synchronous plugin code or retained callbacks.
330
+
331
+ Synthetic probe APIs give each invoked callback a 30-second default budget.
332
+ Set `timeoutMs` or `PLUGIN_INSPECTOR_PROBE_TIMEOUT_MS`; the same finite positive
333
+ API-then-environment validation applies, with no zero or infinite opt-out.
334
+ A timed-out callback becomes a failed row and remaining dependent probes are
335
+ blocked. Ordinary handler failures remain failed rows without stopping
336
+ independent probes. Caller `signal` cancellation rejects the API call.
337
+ Timeout and cancellation abort supported handler signal arguments and observe
338
+ late promise settlement, but cannot preempt synchronous JavaScript or arbitrary
339
+ plugin side effects. Programmatic probes stay in-process and preserve caller
340
+ runtime objects and retained callback identity.
341
+
342
+ Gateway method probes invoke each registered handler once, including positional
343
+ `(method, handler, options)` registrations; captured `handler`/`run`/`execute`
344
+ aliases do not create extra calls. The handler receives synthetic Gateway
345
+ options and a void `respond(ok, payload, error, meta)` callback. Existing
346
+ `registrationProbeInputs` overrides remain available.
347
+
348
+ For a method that needs unavailable host state or live credentials, pass
349
+ `gatewayMethodPrerequisites: { "fixture.account": "saved account required" }`
350
+ to `runCapturedSyntheticProbes` or `runEntrypointSyntheticProbes`. The named
351
+ method produces a `blocked` row with its method and reason before its handler
352
+ runs. Other methods retain normal response validation. Once a caller supplies
353
+ the required inputs and runtime, omit that method from the prerequisite map;
354
+ rejected or malformed responses still fail. This option never reports a
355
+ missing prerequisite as a passing runtime check.
356
+
357
+ The first emitted response is authoritative, even when malformed. Probes check
358
+ its JSON-serialized response/error shape: `ok: true` passes, `ok: false` fails,
359
+ and later responses cannot overwrite the outcome. Logging `meta` is not a wire
360
+ field. Without an explicit response, a non-`undefined` return is adapted into a
361
+ successful payload, including `false`, `null`, and `{ ok: false }`, matching the
362
+ OpenClaw plugin registrar. A void handler can respond later within the existing
363
+ probe deadline; no response or return before that deadline fails. Handler throws,
364
+ rejections, and timeouts still fail under the ordinary probe rules.
365
+
366
+ This observes the initial RPC response plus ordinary handler settlement within
367
+ the existing deadline. It does not prove asynchronous operation completion,
368
+ transport delivery, or authorization. An accepted-only success is valid once the
369
+ handler settles; probes do not implicitly request `expectFinal` or reject legal
370
+ multi-frame methods. No Gateway connection or live host call is made.
371
+
372
+ `synthetic-probes-cli.js` runs capture and retained callbacks together in one
373
+ owned child. Its whole-child budget also defaults to 30 seconds, including
374
+ imports and registration, with `PLUGIN_INSPECTOR_PROBE_TIMEOUT_MS`,
375
+ `PLUGIN_INSPECTOR_PROBE_KILL_GRACE_MS`, and
376
+ `PLUGIN_INSPECTOR_PROBE_MAX_OUTPUT_BYTES` overrides. Shutdown uses the same
377
+ bounded grace and process-group cleanup as capture. The default report and
378
+ per-pipe limit is 10 MiB, matching capture; intercepted plugin stdout and stderr are each capped
379
+ at 1 MiB and kept separate from the report protocol.
380
+
381
+ Completed synthetic reports are still written even when they contain failed
382
+ probe rows; the CLI exits successfully after delivering them, and CI policy
383
+ evaluates those rows. Child timeout, cancellation, truncated output, or an
384
+ oversized or malformed report instead exits unsuccessfully without writing a
385
+ new output artifact. The CLI validates the report shape, row identities and
386
+ statuses, and summary counts before publication; valid empty and blocked
387
+ reports are preserved. This is protocol validation, not authenticated
388
+ completion or a security sandbox: same-process plugin code can still fabricate
389
+ a valid report. Healthy retained intervals cannot keep the child alive after
390
+ its complete report is flushed.
391
+
253
392
  ## CI
254
393
 
255
394
  `plugin-inspector ci` writes the normal compatibility report plus CI-native
@@ -444,6 +583,8 @@ npm run check
444
583
  ```
445
584
 
446
585
  `npm run check` runs the Node test suite and the package-contents guard. The
586
+ test runner limits parallel test files to four so process-supervision tests do
587
+ not contend with a machine-wide burst of child processes for their deadlines. The
447
588
  contents guard shells through `npm pack --dry-run --json` and verifies the npm
448
589
  tarball includes package entrypoints, examples, README assets, and no private
449
590
  `test/`, `scripts/`, or `.github/` paths.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/plugin-inspector",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "private": false,
5
5
  "description": "Offline compatibility inspector for OpenClaw plugins.",
6
6
  "type": "module",
@@ -57,7 +57,7 @@
57
57
  "release:readiness": "npm run release:local && npm run release:crabpot",
58
58
  "release:local": "npm run check",
59
59
  "release:notes": "node scripts/release-notes.mjs --unreleased",
60
- "test": "node --test test/*.test.js",
60
+ "test": "node --test --test-concurrency=4 test/*.test.js",
61
61
  "check:changed": "npm run check",
62
62
  "test:changed": "npm test",
63
63
  "crabbox:hydrate": "crabbox actions hydrate",
@@ -72,6 +72,8 @@
72
72
  "ci"
73
73
  ],
74
74
  "dependencies": {
75
+ "acorn": "^8.18.0",
76
+ "eslint-scope": "^8.4.0",
75
77
  "semver": "^7.8.5",
76
78
  "tar": "^7.5.22"
77
79
  }
package/src/api.js CHANGED
@@ -226,6 +226,13 @@ export async function runPluginCheck(options = {}) {
226
226
  mockSdk,
227
227
  report,
228
228
  rootDir: config.rootDir,
229
+ isolateCapture: options.isolateCapture,
230
+ timeoutMs: options.timeoutMs,
231
+ killGraceMs: options.killGraceMs,
232
+ maxOutputBytes: options.maxOutputBytes,
233
+ signal: options.signal,
234
+ env: options.env,
235
+ apiOptions: options.apiOptions,
229
236
  });
230
237
  const runtimeCapturePaths = await writeRuntimeCaptureReport(runtimeCapture, {
231
238
  jsonPath: path.resolve(config.rootDir, outDir, "plugin-inspector-runtime-capture.json"),
package/src/artifacts.js CHANGED
@@ -1,6 +1,27 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { mkdir, open, readFile, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
 
5
+ export async function readBoundedJsonArtifact(filePath, maxBytes) {
6
+ const file = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
7
+ try {
8
+ const stat = await file.stat();
9
+ if (!stat.isFile()) throw new Error("expected a regular result file");
10
+ if (stat.size > maxBytes) throw new Error("result exceeded its byte limit");
11
+ const chunks = [];
12
+ let bytes = 0;
13
+ // end is inclusive: read at most limit + 1 even if the file grew after stat.
14
+ for await (const chunk of file.createReadStream({ end: maxBytes, autoClose: false })) {
15
+ chunks.push(chunk);
16
+ bytes += chunk.length;
17
+ }
18
+ if (bytes > maxBytes) throw new Error("result exceeded its byte limit");
19
+ return JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
20
+ } finally {
21
+ await file.close();
22
+ }
23
+ }
24
+
4
25
  export async function writeArtifacts(artifacts, options = {}) {
5
26
  if (!Array.isArray(artifacts) || artifacts.length === 0) {
6
27
  throw new TypeError("writeArtifacts requires at least one artifact");
package/src/batch.js CHANGED
@@ -21,7 +21,11 @@ export async function runBatchAnalysis(options = {}) {
21
21
  const rootDir = path.resolve(options.rootDir ?? options.inputDir ?? process.cwd());
22
22
  const outDir = options.outDir ?? "reports";
23
23
  const outRoot = path.resolve(rootDir, outDir);
24
- const concurrency = Math.max(1, Math.min(Math.round(options.concurrency ?? 4), 32));
24
+ const requestedConcurrency = options.concurrency ?? 4;
25
+ if (!Number.isFinite(requestedConcurrency)) {
26
+ throw new TypeError("batch concurrency must be a finite number");
27
+ }
28
+ const concurrency = Math.max(1, Math.min(Math.round(requestedConcurrency), 32));
25
29
  const keepPluginReports = options.keepPluginReports === true;
26
30
  const targetOpenClaw =
27
31
  options.targetOpenClaw ??
@@ -35,8 +39,8 @@ export async function runBatchAnalysis(options = {}) {
35
39
  try {
36
40
  await runWithConcurrency(pluginRoots, concurrency, async (pluginRoot) => {
37
41
  const reportsRoot = keepPluginReports
38
- ? path.join(outRoot, "plugins", slugForPath(path.relative(rootDir, pluginRoot)))
39
- : path.join(tempRoot, slugForPath(path.relative(rootDir, pluginRoot)));
42
+ ? path.join(outRoot, "plugins", path.relative(rootDir, pluginRoot))
43
+ : path.join(tempRoot, path.relative(rootDir, pluginRoot));
40
44
  entries.push(
41
45
  await inspectBatchPlugin(pluginRoot, {
42
46
  ...options,
@@ -306,12 +310,3 @@ function packageNameFromReport(report) {
306
310
  "plugin"
307
311
  );
308
312
  }
309
-
310
- function slugForPath(value) {
311
- return (
312
- String(value)
313
- .replaceAll(path.sep, "-")
314
- .replace(/[^a-zA-Z0-9._-]+/g, "-")
315
- .replace(/^-+|-+$/g, "") || "plugin"
316
- );
317
- }
@@ -203,6 +203,17 @@ function createRuntimeContext(options) {
203
203
  logger: options.logger ?? console,
204
204
  now: () => new Date(0),
205
205
  tts: runtime.tts ?? {},
206
+ // Synthetic provider IDs pass through; capture never resolves host aliases or credentials.
207
+ modelAuth: {
208
+ resolveProviderIdForAuth: (provider) => provider,
209
+ ensureAuthProfileStore: () => ({ version: 1, profiles: {} }),
210
+ resolveAuthProfileOrder: () => [],
211
+ listProfilesForProvider: () => [],
212
+ isProviderApiKeyConfigured: () => false,
213
+ getApiKeyForModel: rejectCaptureModelAuth,
214
+ getRuntimeAuthForModel: rejectCaptureModelAuth,
215
+ resolveApiKeyForProvider: rejectCaptureModelAuth,
216
+ },
206
217
  state: {
207
218
  resolveStateDir: () => options.stateDir ?? process.cwd(),
208
219
  openBlobStore(storeOptions) {
@@ -228,6 +239,10 @@ function createRuntimeContext(options) {
228
239
  };
229
240
  }
230
241
 
242
+ async function rejectCaptureModelAuth() {
243
+ throw new Error("Model auth is unavailable in capture mocks");
244
+ }
245
+
231
246
  function createBlobStoreContext(options) {
232
247
  const values = new Map();
233
248
  const entryInfo = (key, entry) => ({
@@ -23,7 +23,7 @@ async function run(commandArgs) {
23
23
  throw new Error("capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace");
24
24
  }
25
25
 
26
- const result = await captureEntrypoint(entrypoint, { mockSdk, pluginRoot });
26
+ const result = await captureEntrypoint(entrypoint, { mockSdk, pluginRoot, isolateCapture: true });
27
27
  const json = `${JSON.stringify(result, null, 2)}\n`;
28
28
  if (outputPath) {
29
29
  await writeArtifacts([{ path: outputPath, content: json }]);
@@ -6,7 +6,7 @@ export async function captureApiOptionsForPlugin(apiOptions = {}, options = {})
6
6
  return apiOptions;
7
7
  }
8
8
 
9
- const pluginConfig = await readSamplePluginConfig(options.pluginRoot);
9
+ const pluginConfig = await readSamplePluginConfig(options.pluginRoot, options.signal);
10
10
  if (pluginConfig === undefined) {
11
11
  return apiOptions;
12
12
  }
@@ -16,15 +16,16 @@ export async function captureApiOptionsForPlugin(apiOptions = {}, options = {})
16
16
  };
17
17
  }
18
18
 
19
- async function readSamplePluginConfig(pluginRoot) {
20
- const manifestPath = await findNearestManifestPath(pluginRoot);
19
+ async function readSamplePluginConfig(pluginRoot, signal) {
20
+ const manifestPath = await findNearestManifestPath(pluginRoot, signal);
21
21
  if (!manifestPath) {
22
22
  return undefined;
23
23
  }
24
24
  let manifest;
25
25
  try {
26
- manifest = JSON.parse(await readFile(manifestPath, "utf8"));
26
+ manifest = JSON.parse(await readFile(manifestPath, { encoding: "utf8", signal }));
27
27
  } catch {
28
+ signal?.throwIfAborted();
28
29
  return undefined;
29
30
  }
30
31
 
@@ -32,14 +33,16 @@ async function readSamplePluginConfig(pluginRoot) {
32
33
  return isPlainObject(sample) && Object.keys(sample).length > 0 ? sample : undefined;
33
34
  }
34
35
 
35
- async function findNearestManifestPath(pluginRoot) {
36
+ async function findNearestManifestPath(pluginRoot, signal) {
36
37
  let current = path.resolve(pluginRoot);
37
38
  while (true) {
38
39
  const manifestPath = path.join(current, "openclaw.plugin.json");
39
40
  try {
40
- await readFile(manifestPath, "utf8");
41
+ await readFile(manifestPath, { encoding: "utf8", signal });
41
42
  return manifestPath;
42
- } catch {}
43
+ } catch {
44
+ signal?.throwIfAborted();
45
+ }
43
46
 
44
47
  const parent = path.dirname(current);
45
48
  if (parent === current) {
package/src/cli.js CHANGED
@@ -61,7 +61,9 @@ async function runBatch(commandArgs) {
61
61
  const outDir = readFlag(commandArgs, "--out") ?? "reports";
62
62
  const openclawPath = commandArgs.includes("--no-openclaw") ? false : readFlag(commandArgs, "--openclaw");
63
63
  const openclawVersion = readOpenClawVersion(commandArgs);
64
- const concurrency = Number(readFlag(commandArgs, "--concurrency") ?? "4");
64
+ const concurrency = commandArgs.includes("--concurrency")
65
+ ? Number(readFlag(commandArgs, "--concurrency") ?? NaN)
66
+ : 4;
65
67
  const json = commandArgs.includes("--json");
66
68
  const check = commandArgs.includes("--check");
67
69
  const keepPluginReports = commandArgs.includes("--keep-plugin-reports");
@@ -115,6 +117,7 @@ async function runCheck(commandArgs, options = {}) {
115
117
  const ciOutputs = readCiOutputFlags(commandArgs);
116
118
  const authorFacing = readAuthorFacingFlag(commandArgs);
117
119
  const { report, paths } = await runPluginCheck({
120
+ isolateCapture: true,
118
121
  allowExecution,
119
122
  authorFacing,
120
123
  capture,
@@ -279,6 +282,7 @@ async function runCiCompatibilityReport({
279
282
  }
280
283
 
281
284
  const { report } = await runPluginCheck({
285
+ isolateCapture: true,
282
286
  allowExecution,
283
287
  authorFacing,
284
288
  capture,
@@ -307,7 +311,7 @@ async function runCapture(commandArgs) {
307
311
  throw new Error("capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 or --allow-execute in an isolated workspace");
308
312
  }
309
313
 
310
- const result = await captureEntrypoint(entrypoint, { mockSdk, pluginRoot });
314
+ const result = await captureEntrypoint(entrypoint, { mockSdk, pluginRoot, isolateCapture: true });
311
315
  const json = `${JSON.stringify(result, null, 2)}\n`;
312
316
  if (outputPath) {
313
317
  await writeArtifacts([{ path: outputPath, content: json }]);
@@ -226,15 +226,15 @@ function readinessStatus(blockers) {
226
226
  if (blockers.length === 0) {
227
227
  return "ready";
228
228
  }
229
- if (blockers.some((blocker) => blocker.code === "sdk-alias-required")) {
230
- return "sdk-alias-required";
231
- }
232
229
  if (blockers.some((blocker) => blocker.code === "build-required")) {
233
230
  return "build-required";
234
231
  }
235
232
  if (blockers.some((blocker) => blocker.code === "missing-entrypoint")) {
236
233
  return "missing";
237
234
  }
235
+ if (blockers.some((blocker) => blocker.code === "sdk-alias-required")) {
236
+ return "sdk-alias-required";
237
+ }
238
238
  if (blockers.some((blocker) => blocker.code === "ts-loader-required")) {
239
239
  return "ts-loader-required";
240
240
  }
@@ -550,14 +550,16 @@ export function classifyCompatibilityFixture({ fixture, inspection, fixtureRepor
550
550
 
551
551
  const conversationHooks = inspection.hooks.filter((hook) => conversationAccessHooks.has(hook));
552
552
  const conversationHookDetails = inspection.hookDetails.filter((hook) => conversationAccessHooks.has(hook.name));
553
- if (conversationHooks.length > 0) {
553
+ const conversationCompatRecord = compatRecordForIssueCode("conversation-access-hook");
554
+ const conversationContractCovered = hasActiveTargetContractTests(targetOpenClaw, conversationCompatRecord);
555
+ if (conversationHooks.length > 0 && !conversationContractCovered) {
554
556
  warnings.push({
555
557
  fixture: fixture.id,
556
558
  code: "conversation-access-hook",
557
559
  level: "warning",
558
560
  message: "fixture observes raw model or conversation content and needs privacy-boundary contract probes",
559
561
  evidence: detailEvidence(conversationHookDetails),
560
- compatRecord: compatRecordForIssueCode("conversation-access-hook"),
562
+ compatRecord: conversationCompatRecord,
561
563
  });
562
564
  decisions.push({
563
565
  fixture: fixture.id,
@@ -713,6 +715,15 @@ export function classifyCompatibilityFixture({ fixture, inspection, fixtureRepor
713
715
  return { breakages, warnings, suggestions, logs, decisions };
714
716
  }
715
717
 
718
+ function hasActiveTargetContractTests(targetOpenClaw, compatRecord) {
719
+ if (!compatRecord || !["active", "supported"].includes(targetOpenClaw.compatRecordStatuses?.[compatRecord])) {
720
+ return false;
721
+ }
722
+ const tests = targetOpenClaw.compatRecordTests?.[compatRecord] ?? [];
723
+ const missingTests = targetOpenClaw.compatRecordMissingTests?.[compatRecord] ?? [];
724
+ return tests.length > 0 && missingTests.length === 0;
725
+ }
726
+
716
727
  function classifySdkDeprecations({ fixture, inspection, fixtureReport, warnings, decisions }) {
717
728
  const grouped = new Map();
718
729
  for (const finding of fixtureReport.sdkDeprecations ?? inspection.sdkDeprecations ?? []) {
@@ -883,10 +894,22 @@ function classifySdkImportCoverage({ fixture, fixtureReport, targetOpenClaw, war
883
894
  }
884
895
 
885
896
  const sdkExports = new Set(targetOpenClaw.sdkExports);
886
- const unknownImports = fixtureReport.sdkImportDetails.filter((sdkImport) => !sdkExports.has(sdkImport.specifier));
897
+ const bundledPluginId = bundledOpenClawPluginId(fixture, targetOpenClaw);
898
+ const isBundledFixture = bundledPluginId !== null;
899
+ const privateLocalSdkExports = new Set(targetOpenClaw.privateLocalSdkExports ?? []);
900
+ const unknownImports = fixtureReport.sdkImportDetails.filter(
901
+ (sdkImport) =>
902
+ !sdkExports.has(sdkImport.specifier) &&
903
+ !(isBundledFixture && privateLocalSdkExports.has(sdkImport.specifier)),
904
+ );
887
905
  const reservedSdkExports = new Set(targetOpenClaw.reservedSdkExports ?? []);
888
- const reservedImports = fixtureReport.sdkImportDetails.filter((sdkImport) =>
889
- reservedSdkExports.has(sdkImport.specifier),
906
+ const reservedImports = fixtureReport.sdkImportDetails.filter(
907
+ (sdkImport) =>
908
+ reservedSdkExports.has(sdkImport.specifier) &&
909
+ !(
910
+ bundledPluginId !== null &&
911
+ targetOpenClaw.reservedSdkExportOwners?.[sdkImport.specifier] === bundledPluginId
912
+ ),
890
913
  );
891
914
 
892
915
  if (reservedImports.length === 0 && unknownImports.length === 0) {
@@ -894,7 +917,9 @@ function classifySdkImportCoverage({ fixture, fixtureReport, targetOpenClaw, war
894
917
  fixture: fixture.id,
895
918
  code: "sdk-exports-present",
896
919
  level: "log",
897
- message: "all observed plugin SDK imports exist in target OpenClaw package exports",
920
+ message: isBundledFixture
921
+ ? "all observed plugin SDK imports are exported or declared private-local for bundled plugins"
922
+ : "all observed plugin SDK imports exist in target OpenClaw package exports",
898
923
  evidence: fixtureReport.sdkImports,
899
924
  });
900
925
  return;
@@ -938,6 +963,18 @@ function classifySdkImportCoverage({ fixture, fixtureReport, targetOpenClaw, war
938
963
  }
939
964
  }
940
965
 
966
+ function bundledOpenClawPluginId(fixture, targetOpenClaw) {
967
+ if (fixture.repo !== "local" || !fixture.checkoutPath || !targetOpenClaw.checkoutPath) return null;
968
+ const fixturePath = normalizeRepoPath(fixture.checkoutPath);
969
+ const targetPath = normalizeRepoPath(targetOpenClaw.checkoutPath);
970
+ const relativePath = targetPath === "."
971
+ ? fixturePath
972
+ : fixturePath.startsWith(`${targetPath}/`)
973
+ ? fixturePath.slice(targetPath.length + 1)
974
+ : "";
975
+ return relativePath.match(/^extensions\/([^/]+)(?:\/|$)/)?.[1] ?? null;
976
+ }
977
+
941
978
  function addVersionDerivedFinding({ finding, fixtureReport, targetOpenClaw, breakages, warnings, suggestions }) {
942
979
  const compatibility = targetCompatibility(fixtureReport, targetOpenClaw);
943
980
  if (!compatibility) {