@openclaw/plugin-inspector 0.3.23 → 0.3.25

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,29 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.25 - 2026-09-09
6
+
7
+ ### Fixed
8
+
9
+ - 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.
10
+ - 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.
11
+ - Run `registerService` start, stop, and dispose probes serially so teardown cannot overlap startup.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - Capture plugins that bind `api.runtime.modelAuth` during registration with credential-free defaults; auth acquisition remains an explicit synthetic failure.
18
+ - Report absent build output and missing entrypoints before SDK alias blockers in cold-import readiness, preserving build-required totals and all remediation evidence.
19
+ - 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.
20
+
21
+ ## 0.3.24 - 2026-08-31
22
+
23
+ ### Fixed
24
+
25
+ - Recognize compiled CommonJS plugin factory calls when checking expected channel registrations, preserving source references and excluding factory values passed to wrappers.
26
+ - Classify widget presenters as metadata-only synthetic probes without invoking presentation callbacks.
27
+
5
28
  ## 0.3.23 - 2026-08-30
6
29
 
7
30
  ### Fixed
package/README.md CHANGED
@@ -208,6 +208,8 @@ plugin-inspector --help
208
208
 
209
209
  ## Runtime Capture
210
210
 
211
+ Static inspection recognizes direct plugin factory calls and compiled CommonJS
212
+ calls such as `(0, sdk.defineBundledChannelEntry)(...)` without importing them.
211
213
  Runtime capture imports plugin entrypoints in an isolated subprocess and records
212
214
  what `register(api)` does. Use it when static inspection cannot prove the actual
213
215
  registrations made at runtime.
@@ -230,9 +232,41 @@ That keeps compatibility CI offline and credential-free. It does not call live
230
232
  services, launch OpenClaw, run provider SDKs, or emulate service lifecycle side
231
233
  effects.
232
234
 
235
+ CommonJS SDK mocking, including compiled `.cjs` entrypoints and lazy `require()`
236
+ calls in synthetic handlers, requires Node.js 22.15 or newer with
237
+ `module.registerHooks()`. On older Node versions, upgrade Node.js or use an
238
+ ESM/TypeScript entrypoint. This capability requirement does not change the
239
+ package's Node.js `>=22` engine range or gate existing ESM/TypeScript capture.
240
+ Static inspection also discovers literal CommonJS SDK `require()` references.
241
+
242
+ The default capture `api.runtime.modelAuth` passes synthetic provider IDs through
243
+ unchanged, returns fresh empty auth stores and profile lists, and reports no
244
+ configured API keys. Auth acquisition rejects with a mock-auth-unavailable error;
245
+ it never looks up host credentials. This supports registration and no-auth
246
+ callbacks, not provider alias validation or authenticated execution. An explicitly
247
+ supplied runtime is preserved unchanged, including an empty runtime.
248
+
249
+ Synthetic probes classify widget presenters as metadata-only. They record the
250
+ registration without calling its match, availability, or presentation callbacks,
251
+ including when channel, provider, or lifecycle execution is enabled.
252
+
233
253
  Use `--real-sdk` only when the plugin workspace already has real SDK
234
254
  dependencies installed and you intentionally want that path.
235
255
 
256
+ Real-SDK CLI capture runs in an owned child, including runtime capture enabled
257
+ by flags or plugin config. The parent bounds imports and registration, then
258
+ cleans up retained plugin timers after the child flushes its complete result.
259
+ It uses installed SDK dependencies without loading the mock SDK.
260
+
261
+ The real-SDK programmatic API stays in-process to preserve supplied runtime
262
+ objects and retained handler identity. Its 30-second default deadline reports
263
+ `capture-timeout`, stops later inspector-owned phases, and aborts supported
264
+ setup reads. Caller `signal` cancellation also stops later phases. Neither
265
+ mechanism can preempt a synchronous JavaScript loop, unload an import, stop
266
+ arbitrary plugin side effects, or clear plugin-owned timers in the caller's
267
+ process. Only owned-child capture provides that process-lifetime boundary.
268
+ Override the API budget with `timeoutMs` or `PLUGIN_INSPECTOR_CAPTURE_TIMEOUT_MS`.
269
+
236
270
  Runtime capture writes:
237
271
 
238
272
  - `reports/plugin-inspector-runtime-capture.json`
@@ -244,6 +278,101 @@ Capture one entrypoint directly:
244
278
  plugin-inspector capture ./dist/index.js --mock-sdk --allow-execute
245
279
  ```
246
280
 
281
+ CLI capture, mock-SDK API capture, and import-loop/runtime profiles give each
282
+ child a 30-second budget. Capture reports `capture-timeout`; timed-out profile samples
283
+ always have a nonzero `exitCode`, even if a SIGTERM handler exits zero.
284
+ Pass an `AbortSignal` as `signal` to cancel owned-child work. Cancellation is
285
+ never a successful capture or profile sample.
286
+
287
+ On POSIX, each child owns a separate process group. Completion waits for
288
+ stdout/stderr to close and cleans descendants, including after a successful
289
+ leader exit. Shutdown sends SIGTERM, then SIGKILL after a 1-second grace
290
+ period. A further 1-second close deadline fails the operation if pipes remain
291
+ open. Descendants that deliberately leave the group are not contained;
292
+ this is lifecycle supervision, not a sandbox. Windows retains direct-child
293
+ termination and the bounded close deadline, not POSIX group cleanup.
294
+
295
+ The API options `timeoutMs`, `killGraceMs`, and `maxOutputBytes` take precedence
296
+ over `PLUGIN_INSPECTOR_CAPTURE_TIMEOUT_MS`, `PLUGIN_INSPECTOR_CAPTURE_KILL_GRACE_MS`,
297
+ and `PLUGIN_INSPECTOR_CAPTURE_MAX_OUTPUT_BYTES` for owned-child capture. Profiles use
298
+ the corresponding `PLUGIN_INSPECTOR_PROFILE_*` variables. Values must be finite
299
+ positive numbers (zero does not disable limits); invalid values fall through
300
+ to the environment, then defaults. Durations/byte limits cannot exceed
301
+ 2,147,483,647; grace cannot exceed 30,000 ms.
302
+
303
+ Each profiled stdout/stderr stream retains at most 1 MiB by default while
304
+ continuing to drain output. Owned-child capture retains at most 10 MiB per pipe and
305
+ fails if its JSON response is truncated; intercepted plugin stdout/stderr
306
+ inside that response retains at most 1 MiB each. The optional `ps` sampler
307
+ also has bounded output, execution, and cleanup.
308
+
309
+ Default import-loop profiles launch the mock capture runner directly under one
310
+ profile budget, for both baseline and plugin samples. Their JSON artifacts
311
+ retain capture's 10 MiB default limit (`PLUGIN_INSPECTOR_CAPTURE_MAX_OUTPUT_BYTES`,
312
+ or an explicit `maxOutputBytes` override), separately from the profile's
313
+ 1 MiB stdout/stderr limits. Artifacts are accepted only after a successful
314
+ current capture. RSS and CPU now measure the actual runner, and wall time no
315
+ longer includes intermediate CLI startup. Historical measurements from the
316
+ CLI-wrapper route are not directly comparable. Custom `captureCommand` and
317
+ `captureScript` launch contracts are unchanged; custom detached groups are
318
+ outside the owned process group.
319
+
320
+ These limits apply to owned child processes only. The public in-process
321
+ `captureEntrypoint` path preserves retained handler identity and does not
322
+ claim to cancel synchronous plugin code or retained callbacks.
323
+
324
+ Synthetic probe APIs give each invoked callback a 30-second default budget.
325
+ Set `timeoutMs` or `PLUGIN_INSPECTOR_PROBE_TIMEOUT_MS`; the same finite positive
326
+ API-then-environment validation applies, with no zero or infinite opt-out.
327
+ A timed-out callback becomes a failed row and remaining dependent probes are
328
+ blocked. Ordinary handler failures remain failed rows without stopping
329
+ independent probes. Caller `signal` cancellation rejects the API call.
330
+ Timeout and cancellation abort supported handler signal arguments and observe
331
+ late promise settlement, but cannot preempt synchronous JavaScript or arbitrary
332
+ plugin side effects. Programmatic probes stay in-process and preserve caller
333
+ runtime objects and retained callback identity.
334
+
335
+ Gateway method probes invoke each registered handler once, including positional
336
+ `(method, handler, options)` registrations; captured `handler`/`run`/`execute`
337
+ aliases do not create extra calls. The handler receives synthetic Gateway
338
+ options and a void `respond(ok, payload, error, meta)` callback. Existing
339
+ `registrationProbeInputs` overrides remain available.
340
+
341
+ The first emitted response is authoritative, even when malformed. Probes check
342
+ its JSON-serialized response/error shape: `ok: true` passes, `ok: false` fails,
343
+ and later responses cannot overwrite the outcome. Logging `meta` is not a wire
344
+ field. Without an explicit response, a non-`undefined` return is adapted into a
345
+ successful payload, including `false`, `null`, and `{ ok: false }`, matching the
346
+ OpenClaw plugin registrar. A void handler can respond later within the existing
347
+ probe deadline; no response or return before that deadline fails. Handler throws,
348
+ rejections, and timeouts still fail under the ordinary probe rules.
349
+
350
+ This observes the initial RPC response plus ordinary handler settlement within
351
+ the existing deadline. It does not prove asynchronous operation completion,
352
+ transport delivery, or authorization. An accepted-only success is valid once the
353
+ handler settles; probes do not implicitly request `expectFinal` or reject legal
354
+ multi-frame methods. No Gateway connection or live host call is made.
355
+
356
+ `synthetic-probes-cli.js` runs capture and retained callbacks together in one
357
+ owned child. Its whole-child budget also defaults to 30 seconds, including
358
+ imports and registration, with `PLUGIN_INSPECTOR_PROBE_TIMEOUT_MS`,
359
+ `PLUGIN_INSPECTOR_PROBE_KILL_GRACE_MS`, and
360
+ `PLUGIN_INSPECTOR_PROBE_MAX_OUTPUT_BYTES` overrides. Shutdown uses the same
361
+ bounded grace and process-group cleanup as capture. The default report and
362
+ per-pipe limit is 10 MiB, matching capture; intercepted plugin stdout and stderr are each capped
363
+ at 1 MiB and kept separate from the report protocol.
364
+
365
+ Completed synthetic reports are still written even when they contain failed
366
+ probe rows; the CLI exits successfully after delivering them, and CI policy
367
+ evaluates those rows. Child timeout, cancellation, truncated output, or an
368
+ oversized or malformed report instead exits unsuccessfully without writing a
369
+ new output artifact. The CLI validates the report shape, row identities and
370
+ statuses, and summary counts before publication; valid empty and blocked
371
+ reports are preserved. This is protocol validation, not authenticated
372
+ completion or a security sandbox: same-process plugin code can still fabricate
373
+ a valid report. Healthy retained intervals cannot keep the child alive after
374
+ its complete report is flushed.
375
+
247
376
  ## CI
248
377
 
249
378
  `plugin-inspector ci` writes the normal compatibility report plus CI-native
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/plugin-inspector",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "private": false,
5
5
  "description": "Offline compatibility inspector for OpenClaw plugins.",
6
6
  "type": "module",
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");
@@ -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
@@ -115,6 +115,7 @@ async function runCheck(commandArgs, options = {}) {
115
115
  const ciOutputs = readCiOutputFlags(commandArgs);
116
116
  const authorFacing = readAuthorFacingFlag(commandArgs);
117
117
  const { report, paths } = await runPluginCheck({
118
+ isolateCapture: true,
118
119
  allowExecution,
119
120
  authorFacing,
120
121
  capture,
@@ -279,6 +280,7 @@ async function runCiCompatibilityReport({
279
280
  }
280
281
 
281
282
  const { report } = await runPluginCheck({
283
+ isolateCapture: true,
282
284
  allowExecution,
283
285
  authorFacing,
284
286
  capture,
@@ -307,7 +309,7 @@ async function runCapture(commandArgs) {
307
309
  throw new Error("capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 or --allow-execute in an isolated workspace");
308
310
  }
309
311
 
310
- const result = await captureEntrypoint(entrypoint, { mockSdk, pluginRoot });
312
+ const result = await captureEntrypoint(entrypoint, { mockSdk, pluginRoot, isolateCapture: true });
311
313
  const json = `${JSON.stringify(result, null, 2)}\n`;
312
314
  if (outputPath) {
313
315
  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
  }
@@ -1,12 +1,12 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
4
+ import { readBoundedJsonArtifact, renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
5
5
  import { resolveFromRoot } from "./path-utils.js";
6
- import { runProfiledProcess } from "./process-profile.js";
6
+ import { resolveProcessLimits, runProfiledProcess } from "./process-profile.js";
7
7
  import { assertRunCount, percentile } from "./stats.js";
8
8
 
9
- const defaultCliPath = fileURLToPath(new URL("./cli.js", import.meta.url));
9
+ const defaultRunnerPath = fileURLToPath(new URL("./mock-sdk-capture-runner.js", import.meta.url));
10
10
 
11
11
  export const defaultImportLoopProfileOptions = {
12
12
  entrypoint: "test/fixtures/lazy-import-plugin.mjs",
@@ -255,18 +255,49 @@ async function runCaptureSample(options) {
255
255
  const outputPath = path.join(outputDir, `${options.sampleName ?? "capture"}-${options.index}.json`);
256
256
  await mkdir(path.dirname(outputPath), { recursive: true });
257
257
 
258
- const command = buildCaptureCommand({ ...options, outputPath });
258
+ const defaultCapture = typeof options.captureCommand !== "function" && !options.captureScript;
259
+ const maxOutputBytes = resolveProcessLimits({
260
+ ...options,
261
+ env: { ...process.env, ...options.env, ...options.captureEnv },
262
+ }, "CAPTURE").maxOutputBytes;
263
+ // Only the built-in route owns these sample files. An early process.exit(0)
264
+ // must not turn a previous capture into this run's successful result.
265
+ if (defaultCapture) await rm(outputPath, { force: true });
266
+ const command = buildCaptureCommand({ ...options, outputPath, maxOutputBytes });
259
267
  const profile = await runProfiledProcess({
260
268
  command: command.command,
261
269
  args: command.args,
262
270
  cwd: command.cwd ?? options.rootDir,
263
- env: { ...process.env, ...command.env },
271
+ env: { ...process.env, ...options.env, ...command.env },
272
+ timeoutMs: options.timeoutMs,
273
+ maxOutputBytes: options.maxOutputBytes,
274
+ killGraceMs: options.killGraceMs,
275
+ signal: options.signal,
264
276
  });
265
- const output = profile.exitCode === 0 ? await readCaptureOutput(outputPath) : null;
277
+ let output = null;
278
+ if (profile.exitCode === 0 && !profile.timedOut && !profile.cancelled) {
279
+ if (defaultCapture) {
280
+ try {
281
+ output = await readCaptureOutput(outputPath, maxOutputBytes);
282
+ } catch (error) {
283
+ profile.exitCode = 1;
284
+ profile.stderrPreview = `Invalid capture artifact: ${error.message}`;
285
+ }
286
+ } else {
287
+ output = await readCaptureOutput(outputPath);
288
+ }
289
+ }
290
+ if (options.signal?.aborted) {
291
+ profile.exitCode = 1;
292
+ profile.cancelled = true;
293
+ output = null;
294
+ }
266
295
 
267
296
  return {
268
297
  index: options.index,
269
298
  exitCode: profile.exitCode,
299
+ timedOut: profile.timedOut === true,
300
+ cancelled: profile.cancelled === true,
270
301
  status: output?.status ?? "failed",
271
302
  capturedCount: output?.captured?.length ?? 0,
272
303
  openClawLifecycle: output?.openClawLifecycle ?? null,
@@ -407,15 +438,29 @@ function buildCaptureCommand(options) {
407
438
  }
408
439
  return {
409
440
  command: process.execPath,
410
- args: [defaultCliPath, "capture", options.entrypoint, "--output", options.outputPath],
441
+ args: [
442
+ "--no-warnings",
443
+ "--preserve-symlinks",
444
+ defaultRunnerPath,
445
+ JSON.stringify({
446
+ entrypoint: options.entrypoint,
447
+ cwd: options.rootDir,
448
+ outputPath: options.outputPath,
449
+ maxOutputBytes: options.maxOutputBytes,
450
+ }),
451
+ ],
411
452
  cwd: options.rootDir,
412
453
  env: { PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1", ...options.captureEnv },
413
454
  };
414
455
  }
415
456
 
416
- async function readCaptureOutput(outputPath) {
417
- const { readFile } = await import("node:fs/promises");
418
- return JSON.parse(await readFile(outputPath, "utf8"));
457
+ async function readCaptureOutput(outputPath, maxOutputBytes) {
458
+ if (maxOutputBytes === undefined) return JSON.parse(await readFile(outputPath, "utf8"));
459
+ const result = await readBoundedJsonArtifact(outputPath, maxOutputBytes);
460
+ if (!result || typeof result.status !== "string" || !Array.isArray(result.captured)) {
461
+ throw new Error("expected a capture status and captured contracts");
462
+ }
463
+ return result;
419
464
  }
420
465
 
421
466
  function markdownTable(rows, headers) {