@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/src/sdk-mock.js CHANGED
@@ -1,5 +1,8 @@
1
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
2
+ import * as nodeModule from "node:module";
2
3
  import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { collectRuntimeModuleImports } from "./runtime-imports.js";
3
6
 
4
7
  const SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
5
8
  const SKIP_DIRS = new Set([".git", "coverage", "node_modules", "reports"]);
@@ -162,6 +165,14 @@ export const mockSdkSubpathExports = {
162
165
  "normalizeSecretInputString",
163
166
  ],
164
167
  "plugin-runtime": ["createLoggerBackedRuntime", "createSubsystemLogger"],
168
+ "lazy-runtime": [
169
+ "createLazyRuntimeModule",
170
+ "createLazyRuntimeMethod",
171
+ "createLazyRuntimeMethodBinder",
172
+ "createLazyRuntimeNamedExport",
173
+ "createLazyRuntimeSurface",
174
+ ],
175
+ "error-runtime": ["formatErrorMessage"],
165
176
  "secret-input": [
166
177
  "buildOptionalSecretInputSchema",
167
178
  "buildSecretInputArraySchema",
@@ -305,18 +316,48 @@ export async function createMockSdkPackage(rootDir, options = {}) {
305
316
 
306
317
  const fallbackExternalPath = path.join(externalDir, "__fallback__.js");
307
318
  await writeFile(fallbackExternalPath, externalMockModuleSource("__fallback__", new Set()), "utf8");
319
+ const roots = new Set();
320
+ for (const root of [options.pluginRoot, rootDir].filter(Boolean)) {
321
+ roots.add(path.resolve(root));
322
+ roots.add(await realpath(root));
323
+ }
308
324
  const loaderPath = path.join(rootDir, "mock-loader.mjs");
309
- await writeFile(
310
- loaderPath,
311
- mockLoaderSource({
325
+ const syncLoaderPath = path.join(rootDir, "mock-loader-sync.mjs");
326
+ for (const [filePath, synchronous] of [[loaderPath, false], [syncLoaderPath, true]]) {
327
+ await writeFile(filePath, mockLoaderSource({
312
328
  externalMap,
313
329
  fallbackExternalPath,
314
330
  pluginSdkDir,
315
- }),
316
- "utf8",
317
- );
331
+ roots: [...roots],
332
+ requireFiles: [...imports.requireFiles],
333
+ synchronous,
334
+ }), "utf8");
335
+ }
318
336
 
319
- return { packageDir, loaderPath, pluginSdkDir };
337
+ return { packageDir, loaderPath, syncLoaderPath, pluginSdkDir };
338
+ }
339
+
340
+ export async function installMockSdkLoader(mockPackage) {
341
+ const supportsCommonJs = typeof nodeModule.registerHooks === "function";
342
+ // Async hooks cannot deregister. Shared state makes them inert after this capture.
343
+ const active = new Int32Array(new SharedArrayBuffer(4));
344
+ Atomics.store(active, 0, 1);
345
+ nodeModule.register(pathToFileURL(mockPackage.loaderPath), { data: { active, supportsCommonJs } });
346
+ let hooks;
347
+ try {
348
+ if (supportsCommonJs) {
349
+ const { resolve } = await import(pathToFileURL(mockPackage.syncLoaderPath).href);
350
+ // Keep CommonJS loading native; a sync load hook changes its import path on Node 22.15.
351
+ hooks = nodeModule.registerHooks({ resolve });
352
+ }
353
+ } catch (error) {
354
+ Atomics.store(active, 0, 0);
355
+ throw error;
356
+ }
357
+ return () => {
358
+ hooks?.deregister();
359
+ Atomics.store(active, 0, 0);
360
+ };
320
361
  }
321
362
 
322
363
  function emptyRuntimeImports() {
@@ -324,6 +365,7 @@ function emptyRuntimeImports() {
324
365
  bySpecifier: new Map(),
325
366
  openclawSdkSpecifiers: new Set(["openclaw/plugin-sdk"]),
326
367
  bareSpecifiers: new Set(),
368
+ requireFiles: new Set(),
327
369
  };
328
370
  }
329
371
 
@@ -331,6 +373,7 @@ async function collectRuntimeImports(pluginRoot) {
331
373
  const bySpecifier = new Map();
332
374
  const openclawSdkSpecifiers = new Set(["openclaw/plugin-sdk"]);
333
375
  const bareSpecifiers = new Set();
376
+ const requireFiles = new Set();
334
377
  for (const filePath of await listSourceFiles(pluginRoot)) {
335
378
  const text = await readFile(filePath, "utf8");
336
379
  for (const entry of parseModuleImports(text)) {
@@ -341,6 +384,10 @@ async function collectRuntimeImports(pluginRoot) {
341
384
  } else {
342
385
  continue;
343
386
  }
387
+ if (entry.require && !requireFiles.has(path.resolve(filePath))) {
388
+ requireFiles.add(path.resolve(filePath));
389
+ requireFiles.add(await realpath(filePath));
390
+ }
344
391
  const names = bySpecifier.get(entry.specifier) ?? new Set();
345
392
  for (const name of entry.names) {
346
393
  names.add(name);
@@ -348,7 +395,7 @@ async function collectRuntimeImports(pluginRoot) {
348
395
  bySpecifier.set(entry.specifier, names);
349
396
  }
350
397
  }
351
- return { bySpecifier, openclawSdkSpecifiers, bareSpecifiers };
398
+ return { bySpecifier, openclawSdkSpecifiers, bareSpecifiers, requireFiles };
352
399
  }
353
400
 
354
401
  async function listSourceFiles(dir) {
@@ -392,6 +439,9 @@ function parseModuleImports(text) {
392
439
  for (const match of text.matchAll(/\bimport\s+["']([^"']+)["']/g)) {
393
440
  entries.push({ specifier: match[1], names: new Set() });
394
441
  }
442
+ for (const { specifier, names, kind } of collectRuntimeModuleImports(text)) {
443
+ entries.push({ specifier, names, require: kind === "require" });
444
+ }
395
445
  return entries;
396
446
  }
397
447
 
@@ -417,6 +467,7 @@ function parseNamedImports(clause) {
417
467
 
418
468
  function isMockableBareSpecifier(specifier) {
419
469
  return (
470
+ !nodeModule.isBuiltin(specifier) &&
420
471
  !specifier.startsWith(".") &&
421
472
  !specifier.startsWith("/") &&
422
473
  !specifier.startsWith("node:") &&
@@ -429,7 +480,9 @@ function safeModuleFileName(specifier) {
429
480
  return specifier.replace(/[^A-Za-z0-9._-]+/gu, "__");
430
481
  }
431
482
 
432
- function mockLoaderSource({ externalMap, fallbackExternalPath, pluginSdkDir }) {
483
+ function mockLoaderSource({ externalMap, fallbackExternalPath, pluginSdkDir, roots, requireFiles, synchronous }) {
484
+ const asyncKeyword = synchronous ? "" : "async ";
485
+ const awaitKeyword = synchronous ? "" : "await ";
433
486
  return `import { existsSync } from "node:fs";
434
487
  import { readFile } from "node:fs/promises";
435
488
  import { builtinModules, stripTypeScriptTypes } from "node:module";
@@ -439,9 +492,30 @@ import { fileURLToPath, pathToFileURL } from "node:url";
439
492
  const externalMap = new Map(Object.entries(${JSON.stringify(externalMap)}));
440
493
  const fallbackExternalPath = ${JSON.stringify(fallbackExternalPath)};
441
494
  const pluginSdkDir = ${JSON.stringify(pluginSdkDir)};
495
+ const roots = ${JSON.stringify(roots)};
496
+ const requireFiles = new Set(${JSON.stringify(requireFiles)});
442
497
  const builtins = new Set([...builtinModules, ...builtinModules.map((name) => \`node:\${name}\`)]);
498
+ let active;
499
+ let supportsCommonJs = false;
500
+
501
+ export function initialize(data) {
502
+ active = data?.active;
503
+ supportsCommonJs = data?.supportsCommonJs === true;
504
+ }
443
505
 
444
- export async function resolve(specifier, context, nextResolve) {
506
+ function owns(url) {
507
+ if ((active && Atomics.load(active, 0) === 0) || !url?.startsWith("file:")) return false;
508
+ const filePath = fileURLToPath(url);
509
+ return roots.some((root) => {
510
+ const relative = path.relative(root, filePath);
511
+ return relative === "" || (!relative.startsWith(\`..\${path.sep}\`) && relative !== ".." && !path.isAbsolute(relative));
512
+ });
513
+ }
514
+
515
+ export ${asyncKeyword}function resolve(specifier, context, nextResolve) {
516
+ if (!owns(context.parentURL) || builtins.has(specifier) || specifier.startsWith("node:")) {
517
+ return nextResolve(specifier, context);
518
+ }
445
519
  if (specifier === "openclaw/plugin-sdk") {
446
520
  return moduleUrl(path.join(pluginSdkDir, "index.js"));
447
521
  }
@@ -458,7 +532,7 @@ export async function resolve(specifier, context, nextResolve) {
458
532
  return moduleUrl(externalMap.get(specifier));
459
533
  }
460
534
  try {
461
- return await nextResolve(specifier, context);
535
+ return ${awaitKeyword}nextResolve(specifier, context);
462
536
  } catch (error) {
463
537
  const resolved = resolveExtensionless(specifier, context.parentURL);
464
538
  if (resolved) {
@@ -472,11 +546,16 @@ export async function resolve(specifier, context, nextResolve) {
472
546
  }
473
547
 
474
548
  export async function load(url, context, nextLoad) {
549
+ if (!owns(url)) return nextLoad(url, context);
475
550
  if (url.startsWith("file:") && /\\.[cm]?ts$/u.test(fileURLToPath(url))) {
476
551
  const rawSource = await readFile(fileURLToPath(url), "utf8");
477
552
  return { format: "module", source: stripPluginTypeScript(rawSource), shortCircuit: true };
478
553
  }
479
- return nextLoad(url, context);
554
+ const result = await nextLoad(url, context);
555
+ ${synchronous ? "" : `if (!supportsCommonJs && result.format === "commonjs" && requireFiles.has(fileURLToPath(url))) {
556
+ throw new Error("CommonJS SDK mocking requires Node.js 22.15 or newer with module.registerHooks(); upgrade Node.js or use an ESM/TypeScript entrypoint.");
557
+ }`}
558
+ return result;
480
559
  }
481
560
 
482
561
  function stripPluginTypeScript(source) {
@@ -621,6 +700,11 @@ function isValidExportName(name) {
621
700
  }
622
701
 
623
702
  function genericExportStatement(name) {
703
+ if (name === "normalizeOptionalString") {
704
+ // Optional values stay absent; an empty string invents explicit input in
705
+ // callers that distinguish undefined from a configured policy value.
706
+ return 'export function normalizeOptionalString(value) { return typeof value === "string" ? value.trim() || undefined : undefined; }';
707
+ }
624
708
  if (name === "isRecord") {
625
709
  return "export function isRecord(value) { return isPlainObject(value); }";
626
710
  }
@@ -1638,6 +1722,37 @@ export function resolveRuntimeEnv(env = {}) {
1638
1722
  return createRuntimeEnv(env);
1639
1723
  }
1640
1724
 
1725
+ export function createLazyRuntimeSurface(importer, select) {
1726
+ let promise;
1727
+ const load = () => {
1728
+ // SDK runtime imports retain the same promise, including rejection, until clear().
1729
+ promise ??= Promise.resolve().then(() => importer().then(select));
1730
+ return promise;
1731
+ };
1732
+ load.peek = () => promise;
1733
+ load.clear = () => { promise = undefined; };
1734
+ return load;
1735
+ }
1736
+
1737
+ export function createLazyRuntimeModule(importer) {
1738
+ return createLazyRuntimeSurface(importer, (module) => module);
1739
+ }
1740
+
1741
+ export function createLazyRuntimeNamedExport(importer, key) {
1742
+ return createLazyRuntimeSurface(importer, (module) => module[key]);
1743
+ }
1744
+
1745
+ export function createLazyRuntimeMethod(load, select) {
1746
+ return async (...args) => {
1747
+ const method = select(await load());
1748
+ return await method(...args);
1749
+ };
1750
+ }
1751
+
1752
+ export function createLazyRuntimeMethodBinder(load) {
1753
+ return (select) => createLazyRuntimeMethod(load, select);
1754
+ }
1755
+
1641
1756
  export function createLoggerBackedRuntime(logger = console) {
1642
1757
  return { logger };
1643
1758
  }
@@ -1,27 +1,22 @@
1
1
  import { rmSync } from "node:fs";
2
2
  import { mkdtemp } from "node:fs/promises";
3
- import { register } from "node:module";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
- import { pathToFileURL } from "node:url";
7
5
  import { captureEntrypoint } from "./inspector.js";
8
- import { createMockSdkPackage } from "./sdk-mock.js";
6
+ import { createMockSdkPackage, installMockSdkLoader } from "./sdk-mock.js";
9
7
  import { runCapturedSyntheticProbes } from "./synthetic-probes.js";
10
8
 
11
9
  export async function runEntrypointSyntheticProbes(entrypoint, options = {}) {
12
- const capture = await captureEntrypointForSyntheticProbes(entrypoint, {
10
+ const captureOptions = {
13
11
  ...options,
14
12
  apiOptions: {
15
13
  ...(options.apiOptions ?? {}),
16
14
  retainHandlers: true,
17
15
  },
18
- });
19
- return runCapturedSyntheticProbes(capture, options);
20
- }
21
-
22
- async function captureEntrypointForSyntheticProbes(entrypoint, options) {
16
+ };
23
17
  if (options.mockSdk !== true) {
24
- return captureEntrypoint(entrypoint, options);
18
+ const capture = await captureEntrypoint(entrypoint, captureOptions);
19
+ return runCapturedSyntheticProbes(capture, options);
25
20
  }
26
21
 
27
22
  const cwd = options.cwd ?? process.cwd();
@@ -29,15 +24,20 @@ async function captureEntrypointForSyntheticProbes(entrypoint, options) {
29
24
  const pluginRoot = path.resolve(cwd, options.pluginRoot ?? path.dirname(resolvedEntrypoint));
30
25
  const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-synthetic-mock-sdk-"));
31
26
  cleanupTempDirOnExit(workspace);
32
- const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
33
- register(pathToFileURL(loaderPath));
34
-
35
- return captureEntrypoint(entrypoint, {
36
- ...options,
37
- cwd,
38
- mockSdk: false,
39
- pluginRoot,
40
- });
27
+ const mockPackage = await createMockSdkPackage(workspace, { pluginRoot });
28
+ const stopLoader = await installMockSdkLoader(mockPackage);
29
+ try {
30
+ const capture = await captureEntrypoint(entrypoint, {
31
+ ...captureOptions,
32
+ cwd,
33
+ mockSdk: false,
34
+ pluginRoot,
35
+ });
36
+ // Retained handlers can require SDK modules lazily during invocation.
37
+ return await runCapturedSyntheticProbes(capture, options);
38
+ } finally {
39
+ stopLoader();
40
+ }
41
41
  }
42
42
 
43
43
  function cleanupTempDirOnExit(dir) {
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { runEntrypointSyntheticProbes, writeArtifacts } from "./advanced.js";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { readBoundedJsonArtifact, writeArtifacts } from "./artifacts.js";
7
+ import { resolveProcessLimits, startOwnedProcess } from "./process-profile.js";
3
8
 
4
9
  const args = process.argv.slice(2);
5
10
 
@@ -26,7 +31,7 @@ async function run(commandArgs) {
26
31
  throw new Error("synthetic probes import plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace");
27
32
  }
28
33
 
29
- const results = await runEntrypointSyntheticProbes(entrypoint, {
34
+ const results = await runInChild(entrypoint, {
30
35
  mockSdk,
31
36
  pluginRoot,
32
37
  apiOptions: { retainHandlers: true },
@@ -43,6 +48,78 @@ async function run(commandArgs) {
43
48
  }
44
49
  }
45
50
 
51
+ async function runInChild(entrypoint, options) {
52
+ const limits = resolveProcessLimits({}, "PROBE");
53
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-synthetic-cli-"));
54
+ const outputPath = path.join(workspace, "result.json");
55
+ const controller = new AbortController();
56
+ const cancel = () => controller.abort(new Error("Synthetic probes cancelled"));
57
+ process.once("SIGINT", cancel);
58
+ process.once("SIGTERM", cancel);
59
+ try {
60
+ const runnerPath = fileURLToPath(new URL("./mock-sdk-capture-runner.js", import.meta.url));
61
+ const { result } = startOwnedProcess({
62
+ command: process.execPath,
63
+ args: [
64
+ "--no-warnings",
65
+ ...(options.mockSdk ? ["--preserve-symlinks"] : []),
66
+ runnerPath,
67
+ JSON.stringify({
68
+ ...options, ...limits, entrypoint, outputPath,
69
+ cwd: process.cwd(), syntheticProbes: true,
70
+ }),
71
+ ],
72
+ ...limits,
73
+ signal: controller.signal,
74
+ }, "PROBE");
75
+ const outcome = await result;
76
+ if (outcome.exitCode !== 0 || outcome.outputTruncated) {
77
+ const message = outcome.cancelled ? "Synthetic probes cancelled"
78
+ : outcome.timedOut ? `Synthetic probes timed out after ${limits.timeoutMs}ms`
79
+ : outcome.outputTruncated ? "Synthetic probe child output exceeded its byte limit"
80
+ : outcome.stderr.trim() || outcome.error?.message || "Synthetic probe child failed";
81
+ throw new Error(message);
82
+ }
83
+ controller.signal.throwIfAborted();
84
+ // The report is separate from plugin stdout, including direct fd writes.
85
+ // Only accept a fresh complete artifact after successful child cleanup.
86
+ const results = await readBoundedJsonArtifact(outputPath, limits.maxOutputBytes);
87
+ validateSyntheticReport(results);
88
+ controller.signal.throwIfAborted();
89
+ return results;
90
+ } finally {
91
+ process.removeListener("SIGINT", cancel);
92
+ process.removeListener("SIGTERM", cancel);
93
+ await rm(workspace, { recursive: true, force: true });
94
+ }
95
+ }
96
+
97
+ function validateSyntheticReport(report) {
98
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
99
+ if (!isObject(report) || typeof report.entrypoint !== "string" ||
100
+ !["captured", "no-register-export"].includes(report.status) ||
101
+ !isObject(report.summary) || !Array.isArray(report.results)) {
102
+ throw new Error("Invalid synthetic probe report: expected entrypoint, status, summary, and results");
103
+ }
104
+ const counts = { probeCount: report.results.length, passCount: 0, failCount: 0, blockedCount: 0 };
105
+ for (const row of report.results) {
106
+ if (!isObject(row) || !Number.isSafeInteger(row.captureIndex) || row.captureIndex < 0 ||
107
+ !["kind", "seam", "label"].every((key) => typeof row[key] === "string") ||
108
+ !["pass", "fail", "blocked"].includes(row.status) ||
109
+ (row.status === "fail" && typeof row.error !== "string") ||
110
+ (row.status === "blocked" && typeof row.reason !== "string")) {
111
+ throw new Error("Invalid synthetic probe report: malformed result row");
112
+ }
113
+ counts[`${row.status}Count`] += 1;
114
+ }
115
+ for (const [key, expected] of Object.entries(counts)) {
116
+ if (!Number.isSafeInteger(report.summary[key]) || report.summary[key] < 0 ||
117
+ report.summary[key] !== expected) {
118
+ throw new Error(`Invalid synthetic probe report: invalid or inconsistent ${key}`);
119
+ }
120
+ }
121
+ }
122
+
46
123
  function readFlag(commandArgs, name) {
47
124
  const index = commandArgs.indexOf(name);
48
125
  if (index === -1) {