@openclaw/plugin-inspector 0.3.24 → 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.
@@ -1,6 +1,147 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { performance } from "node:perf_hooks";
3
3
 
4
+ const defaultTimeoutMs = 30_000;
5
+ const defaultKillGraceMs = 1_000;
6
+ const maxTimerMs = 2 ** 31 - 1;
7
+ const killWaitMs = 1_000;
8
+
9
+ // Shared by capture and profiling, not a package entrypoint. Each spawn owns
10
+ // its POSIX process group; never signal the inspector's inherited group.
11
+ export function startOwnedProcess(options, kind = "PROFILE") {
12
+ const env = options.env ?? process.env;
13
+ const { timeoutMs, killGraceMs, maxOutputBytes } = resolveProcessLimits(options, kind);
14
+ const stdout = createCappedCollector(maxOutputBytes);
15
+ const stderr = createCappedCollector(maxOutputBytes);
16
+ let timedOut = false;
17
+ let cancelled = options.signal?.aborted === true;
18
+ let error;
19
+ let closed = false;
20
+ let stopping = false;
21
+ let escalated = false;
22
+ let settled = false;
23
+ let code;
24
+ let exitSignal;
25
+ let timeoutId;
26
+ let forceKillId;
27
+ let closeDeadlineId;
28
+ let child;
29
+ let resolveResult;
30
+ const result = new Promise((resolve) => { resolveResult = resolve; });
31
+
32
+ const finish = () => {
33
+ if (settled) return;
34
+ settled = true;
35
+ clearTimeout(timeoutId);
36
+ clearTimeout(forceKillId);
37
+ clearTimeout(closeDeadlineId);
38
+ options.signal?.removeEventListener("abort", cancel);
39
+ resolveResult({
40
+ exitCode: timedOut || cancelled || error ? 1 : (code ?? 1),
41
+ timedOut,
42
+ cancelled,
43
+ timeoutMs,
44
+ signal: exitSignal,
45
+ pid: child?.pid,
46
+ error,
47
+ stdout: stdout.text(),
48
+ stderr: stderr.text(),
49
+ outputTruncated: stdout.truncated || stderr.truncated,
50
+ });
51
+ };
52
+ const groupExists = () => {
53
+ if (!child?.pid) return false;
54
+ if (process.platform === "win32") return child.exitCode === null && child.signalCode === null;
55
+ try {
56
+ process.kill(-child.pid, 0);
57
+ return true;
58
+ } catch (cause) {
59
+ if (cause.code === "ESRCH") return false;
60
+ error ??= cause;
61
+ return true;
62
+ }
63
+ };
64
+ const signalGroup = (signal) => {
65
+ if (!child?.pid) return;
66
+ try {
67
+ if (process.platform === "win32") child.kill(signal);
68
+ else process.kill(-child.pid, signal);
69
+ } catch (cause) {
70
+ if (cause.code !== "ESRCH") error ??= cause;
71
+ }
72
+ };
73
+ const stop = () => {
74
+ if (stopping || settled) return;
75
+ stopping = true;
76
+ signalGroup("SIGTERM");
77
+ forceKillId = setTimeout(() => {
78
+ // The leader may already be reaped while its descendants hold the pipes.
79
+ signalGroup("SIGKILL");
80
+ escalated = true;
81
+ if (closed) {
82
+ finish();
83
+ return;
84
+ }
85
+ closeDeadlineId = setTimeout(() => {
86
+ error ??= new Error("Owned child stdio did not close after SIGKILL");
87
+ child?.stdout?.destroy();
88
+ child?.stderr?.destroy();
89
+ child?.stdin?.destroy();
90
+ child?.unref();
91
+ finish();
92
+ }, killWaitMs);
93
+ }, killGraceMs);
94
+ };
95
+ const cancel = () => {
96
+ cancelled = true;
97
+ stop();
98
+ };
99
+
100
+ if (cancelled) {
101
+ finish();
102
+ return { child, result };
103
+ }
104
+ try {
105
+ child = spawn(options.command, options.args ?? [], {
106
+ cwd: options.cwd,
107
+ env,
108
+ detached: process.platform !== "win32",
109
+ stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
110
+ });
111
+ } catch (cause) {
112
+ error = cause;
113
+ finish();
114
+ return { child, result };
115
+ }
116
+ child.stdout?.on("data", (chunk) => stdout.push(chunk));
117
+ child.stderr?.on("data", (chunk) => stderr.push(chunk));
118
+ const fail = (cause) => {
119
+ error ??= cause;
120
+ stop();
121
+ };
122
+ child.stdout?.on("error", fail);
123
+ child.stderr?.on("error", fail);
124
+ child.once("error", fail);
125
+ child.once("exit", () => {
126
+ // Clean descendants even after a successful leader exit or closed pipes.
127
+ if (groupExists()) stop();
128
+ });
129
+ child.once("close", (exitCode, signal) => {
130
+ closed = true;
131
+ code = exitCode;
132
+ exitSignal = signal;
133
+ if (!escalated && groupExists()) stop();
134
+ else finish();
135
+ });
136
+ timeoutId = setTimeout(() => {
137
+ timedOut = true;
138
+ stop();
139
+ }, timeoutMs);
140
+ options.signal?.addEventListener("abort", cancel, { once: true });
141
+ if (options.signal?.aborted) cancel();
142
+ return { child, result };
143
+ }
144
+
4
145
  export async function runProfiledProcess(options) {
5
146
  const start = performance.now();
6
147
  const heapStartMb = heapUsedMb();
@@ -10,129 +151,136 @@ export async function runProfiledProcess(options) {
10
151
  let statSampleCount = 0;
11
152
  let rssSampleCount = 0;
12
153
  let cpuSampleCount = 0;
13
- const cpuSamples = [];
14
- let pollInFlight = false;
15
- const pendingStats = new Set();
16
-
17
- const child = spawn(options.command, options.args ?? [], {
18
- cwd: options.cwd,
19
- env: options.env,
20
- stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
21
- });
22
- const stdout = [];
23
- const stderr = [];
24
- child.stdout?.on("data", (chunk) => stdout.push(chunk));
25
- child.stderr?.on("data", (chunk) => stderr.push(chunk));
26
-
27
- const recordStats = (stats) => {
28
- if (stats.rssAvailable || stats.cpuAvailable) {
29
- statSampleCount += 1;
30
- }
31
- if (stats.rssAvailable) {
32
- rssSampleCount += 1;
33
- }
34
- if (stats.cpuAvailable) {
35
- cpuSampleCount += 1;
36
- }
37
- if (stats.rssAvailable && stats.rssKb > 0 && firstRssKb === 0) {
38
- firstRssKb = stats.rssKb;
39
- }
40
- if (stats.rssAvailable) {
41
- peakRssKb = Math.max(peakRssKb, stats.rssKb);
42
- }
43
- if (stats.cpuAvailable) {
44
- peakCpuPercent = Math.max(peakCpuPercent, stats.cpuPercent);
45
- cpuSamples.push(stats.cpuPercent);
46
- }
47
- };
48
-
154
+ let cpuTotal = 0;
155
+ let pendingStats;
156
+ let stopped = false;
157
+ const statsController = new AbortController();
158
+ const running = startOwnedProcess(options);
49
159
  const sampleStats = () => {
50
- if (pollInFlight) {
51
- return;
52
- }
53
- pollInFlight = true;
54
- const pending = readProcessStats(child.pid)
55
- .then(recordStats)
56
- .finally(() => {
57
- pollInFlight = false;
58
- pendingStats.delete(pending);
59
- });
60
- pendingStats.add(pending);
160
+ if (pendingStats || stopped || !running.child?.pid) return;
161
+ pendingStats = readProcessStats(running.child.pid, options.env, statsController.signal)
162
+ .then((stats) => {
163
+ if (stopped) return;
164
+ if (stats.rssAvailable || stats.cpuAvailable) statSampleCount += 1;
165
+ if (stats.rssAvailable) {
166
+ rssSampleCount += 1;
167
+ if (stats.rssKb > 0 && firstRssKb === 0) firstRssKb = stats.rssKb;
168
+ peakRssKb = Math.max(peakRssKb, stats.rssKb);
169
+ }
170
+ if (stats.cpuAvailable) {
171
+ cpuSampleCount += 1;
172
+ peakCpuPercent = Math.max(peakCpuPercent, stats.cpuPercent);
173
+ cpuTotal += stats.cpuPercent;
174
+ }
175
+ })
176
+ .finally(() => { pendingStats = undefined; });
61
177
  };
62
-
178
+ const poll = setInterval(sampleStats, positiveLimit(options.pollMs, undefined, 25));
179
+ const stopSampling = () => {
180
+ stopped = true;
181
+ clearInterval(poll);
182
+ statsController.abort();
183
+ };
184
+ running.child?.once("exit", stopSampling);
185
+ running.child?.once("error", stopSampling);
63
186
  sampleStats();
64
- const poll = setInterval(sampleStats, options.pollMs ?? 25);
65
-
66
- const exitCode = await new Promise((resolve, reject) => {
67
- child.on("error", (error) => {
68
- clearInterval(poll);
69
- reject(error);
70
- });
71
- child.on("exit", (code) => resolve(code ?? 1));
72
- });
73
- clearInterval(poll);
74
- await Promise.allSettled([...pendingStats]);
75
-
76
- const finalStats = await readProcessStats(child.pid);
77
- recordStats(finalStats);
78
187
 
79
- const wallMs = Math.round(performance.now() - start);
80
- const averageCpuPercent =
81
- cpuSamples.length > 0
82
- ? cpuSamples.reduce((sum, value) => sum + value, 0) / cpuSamples.length
83
- : 0;
84
- const cpuPercentForEstimate =
85
- options.roundAverageCpuPercent === true
188
+ try {
189
+ const outcome = await running.result;
190
+ stopSampling();
191
+ await pendingStats;
192
+ if (outcome.error) throw outcome.error;
193
+ const wallMs = Math.round(performance.now() - start);
194
+ const averageCpuPercent = cpuSampleCount > 0 ? cpuTotal / cpuSampleCount : 0;
195
+ const cpuPercentForEstimate = options.roundAverageCpuPercent === true
86
196
  ? Math.round(averageCpuPercent * 10) / 10
87
197
  : averageCpuPercent;
198
+ return {
199
+ wallMs,
200
+ peakRssMb: Math.round((peakRssKb / 1024) * 10) / 10,
201
+ rssDeltaMb: Math.round(((peakRssKb - firstRssKb) / 1024) * 10) / 10,
202
+ peakCpuPercent: Math.round(peakCpuPercent * 10) / 10,
203
+ cpuMsEstimate: Math.round((wallMs * cpuPercentForEstimate) / 100),
204
+ harnessHeapDeltaMb: Math.round((heapUsedMb() - heapStartMb) * 10) / 10,
205
+ statSampleCount,
206
+ rssSampleCount,
207
+ cpuSampleCount,
208
+ exitCode: outcome.exitCode,
209
+ timedOut: outcome.timedOut,
210
+ cancelled: outcome.cancelled,
211
+ pid: outcome.pid,
212
+ stdoutPreview: previewLines(outcome.stdout),
213
+ stderrPreview: previewLines(outcome.stderr),
214
+ };
215
+ } finally {
216
+ stopSampling();
217
+ }
218
+ }
88
219
 
220
+ export function resolveProcessLimits(options, kind = "PROFILE") {
221
+ const env = options.env ?? process.env;
222
+ const setting = (name) => env[`PLUGIN_INSPECTOR_${kind}_${name}`] ?? process.env[`PLUGIN_INSPECTOR_${kind}_${name}`];
89
223
  return {
90
- wallMs,
91
- peakRssMb: Math.round((peakRssKb / 1024) * 10) / 10,
92
- rssDeltaMb: Math.round(((peakRssKb - firstRssKb) / 1024) * 10) / 10,
93
- peakCpuPercent: Math.round(peakCpuPercent * 10) / 10,
94
- cpuMsEstimate: Math.round((wallMs * cpuPercentForEstimate) / 100),
95
- harnessHeapDeltaMb: Math.round((heapUsedMb() - heapStartMb) * 10) / 10,
96
- statSampleCount,
97
- rssSampleCount,
98
- cpuSampleCount,
99
- exitCode,
100
- stdoutPreview: previewLines(stdout),
101
- stderrPreview: previewLines(stderr),
224
+ timeoutMs: positiveLimit(options.timeoutMs, setting("TIMEOUT_MS"), defaultTimeoutMs),
225
+ killGraceMs: positiveLimit(options.killGraceMs, setting("KILL_GRACE_MS"), defaultKillGraceMs, 30_000),
226
+ maxOutputBytes: positiveLimit(options.maxOutputBytes, setting("MAX_OUTPUT_BYTES"), (kind === "CAPTURE" || kind === "PROBE" ? 10 : 1) * 1024 * 1024),
102
227
  };
103
228
  }
104
229
 
105
- async function readProcessStats(pid) {
106
- if (!pid || process.platform === "win32") {
107
- return { rssAvailable: false, rssKb: 0, cpuAvailable: false, cpuPercent: 0 };
108
- }
109
- return new Promise((resolve) => {
110
- const ps = spawn("ps", ["-o", "rss=", "-o", "%cpu=", "-p", String(pid)], {
111
- stdio: ["ignore", "pipe", "ignore"],
112
- });
113
- const chunks = [];
114
- ps.stdout.on("data", (chunk) => chunks.push(chunk));
115
- ps.on("error", () => resolve({ rssAvailable: false, rssKb: 0, cpuAvailable: false, cpuPercent: 0 }));
116
- ps.on("exit", () => {
117
- const [rssRaw, cpuRaw] = Buffer.concat(chunks).toString("utf8").trim().split(/\s+/);
118
- const rssKb = Number.parseInt(rssRaw, 10);
119
- const cpuPercent = Number.parseFloat(cpuRaw);
120
- const rssAvailable = Number.isFinite(rssKb);
121
- const cpuAvailable = Number.isFinite(cpuPercent);
122
- resolve({
123
- rssAvailable,
124
- rssKb: rssAvailable ? rssKb : 0,
125
- cpuAvailable,
126
- cpuPercent: cpuAvailable ? cpuPercent : 0,
127
- });
128
- });
230
+ function positiveLimit(option, env, fallback, max = maxTimerMs) {
231
+ const valid = (value) => Number.isFinite(value) && value > 0 && value <= max;
232
+ if (valid(option)) return Math.ceil(option);
233
+ const fromEnv = typeof env === "string" ? Number(env) : NaN;
234
+ return valid(fromEnv) ? Math.ceil(fromEnv) : fallback;
235
+ }
236
+
237
+ export function createCappedCollector(maxBytes) {
238
+ const chunks = [];
239
+ let size = 0;
240
+ let truncated = false;
241
+ return {
242
+ get truncated() { return truncated; },
243
+ push(chunk) {
244
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
245
+ const length = Math.min(buffer.length, maxBytes - size);
246
+ if (length < buffer.length) truncated = true;
247
+ if (length === 0) return;
248
+ chunks.push(Buffer.from(buffer.subarray(0, length)));
249
+ size += length;
250
+ },
251
+ text: () => Buffer.concat(chunks, size).toString("utf8"),
252
+ };
253
+ }
254
+
255
+ async function readProcessStats(pid, env, signal) {
256
+ const unavailable = { rssAvailable: false, rssKb: 0, cpuAvailable: false, cpuPercent: 0 };
257
+ if (!pid || process.platform === "win32") return unavailable;
258
+ const { result } = startOwnedProcess({
259
+ command: "ps",
260
+ args: ["-o", "rss=", "-o", "%cpu=", "-p", String(pid)],
261
+ env,
262
+ signal,
263
+ timeoutMs: 250,
264
+ killGraceMs: 50,
265
+ maxOutputBytes: 4096,
129
266
  });
267
+ const outcome = await result;
268
+ if (outcome.exitCode !== 0 || outcome.outputTruncated) return unavailable;
269
+ const [rssRaw, cpuRaw] = outcome.stdout.trim().split(/\s+/);
270
+ const rssKb = Number.parseInt(rssRaw, 10);
271
+ const cpuPercent = Number.parseFloat(cpuRaw);
272
+ return {
273
+ rssAvailable: Number.isFinite(rssKb),
274
+ rssKb: Number.isFinite(rssKb) ? rssKb : 0,
275
+ cpuAvailable: Number.isFinite(cpuPercent),
276
+ cpuPercent: Number.isFinite(cpuPercent) ? cpuPercent : 0,
277
+ };
130
278
  }
131
279
 
132
280
  function heapUsedMb() {
133
281
  return Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 10) / 10;
134
282
  }
135
283
 
136
- function previewLines(chunks) {
137
- return Buffer.concat(chunks).toString("utf8").trim().split("\n").slice(-2).join("\n");
284
+ function previewLines(text) {
285
+ return text.trim().split("\n").slice(-2).join("\n");
138
286
  }
@@ -12,6 +12,7 @@ export async function buildRuntimeCaptureReport(options = {}) {
12
12
  const results = [];
13
13
  for (const fixture of report.fixtures) {
14
14
  for (const target of captureTargets(fixture, rootDir)) {
15
+ options.signal?.throwIfAborted();
15
16
  results.push(await captureTarget(target, options));
16
17
  }
17
18
  }
@@ -117,6 +118,11 @@ async function captureTarget(target, options) {
117
118
  mockSdk: options.mockSdk !== false,
118
119
  apiOptions: options.apiOptions,
119
120
  env: options.env,
121
+ isolateCapture: options.isolateCapture,
122
+ timeoutMs: options.timeoutMs,
123
+ killGraceMs: options.killGraceMs,
124
+ maxOutputBytes: options.maxOutputBytes,
125
+ signal: options.signal,
120
126
  });
121
127
  return {
122
128
  fixture: target.fixture,
@@ -333,6 +333,10 @@ async function profileCommand(command, options) {
333
333
  env: { ...process.env, ...options.env, ...command.env },
334
334
  stdio: ["ignore", "pipe", "pipe"],
335
335
  roundAverageCpuPercent: true,
336
+ timeoutMs: command.timeoutMs ?? options.timeoutMs,
337
+ maxOutputBytes: command.maxOutputBytes ?? options.maxOutputBytes,
338
+ killGraceMs: command.killGraceMs ?? options.killGraceMs,
339
+ signal: options.signal,
336
340
  });
337
341
  }
338
342
 
package/src/sdk-mock.js CHANGED
@@ -1,5 +1,7 @@
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";
3
5
 
4
6
  const SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
5
7
  const SKIP_DIRS = new Set([".git", "coverage", "node_modules", "reports"]);
@@ -305,18 +307,48 @@ export async function createMockSdkPackage(rootDir, options = {}) {
305
307
 
306
308
  const fallbackExternalPath = path.join(externalDir, "__fallback__.js");
307
309
  await writeFile(fallbackExternalPath, externalMockModuleSource("__fallback__", new Set()), "utf8");
310
+ const roots = new Set();
311
+ for (const root of [options.pluginRoot, rootDir].filter(Boolean)) {
312
+ roots.add(path.resolve(root));
313
+ roots.add(await realpath(root));
314
+ }
308
315
  const loaderPath = path.join(rootDir, "mock-loader.mjs");
309
- await writeFile(
310
- loaderPath,
311
- mockLoaderSource({
316
+ const syncLoaderPath = path.join(rootDir, "mock-loader-sync.mjs");
317
+ for (const [filePath, synchronous] of [[loaderPath, false], [syncLoaderPath, true]]) {
318
+ await writeFile(filePath, mockLoaderSource({
312
319
  externalMap,
313
320
  fallbackExternalPath,
314
321
  pluginSdkDir,
315
- }),
316
- "utf8",
317
- );
322
+ roots: [...roots],
323
+ requireFiles: [...imports.requireFiles],
324
+ synchronous,
325
+ }), "utf8");
326
+ }
318
327
 
319
- return { packageDir, loaderPath, pluginSdkDir };
328
+ return { packageDir, loaderPath, syncLoaderPath, pluginSdkDir };
329
+ }
330
+
331
+ export async function installMockSdkLoader(mockPackage) {
332
+ const supportsCommonJs = typeof nodeModule.registerHooks === "function";
333
+ // Async hooks cannot deregister. Shared state makes them inert after this capture.
334
+ const active = new Int32Array(new SharedArrayBuffer(4));
335
+ Atomics.store(active, 0, 1);
336
+ nodeModule.register(pathToFileURL(mockPackage.loaderPath), { data: { active, supportsCommonJs } });
337
+ let hooks;
338
+ try {
339
+ if (supportsCommonJs) {
340
+ const { resolve } = await import(pathToFileURL(mockPackage.syncLoaderPath).href);
341
+ // Keep CommonJS loading native; a sync load hook changes its import path on Node 22.15.
342
+ hooks = nodeModule.registerHooks({ resolve });
343
+ }
344
+ } catch (error) {
345
+ Atomics.store(active, 0, 0);
346
+ throw error;
347
+ }
348
+ return () => {
349
+ hooks?.deregister();
350
+ Atomics.store(active, 0, 0);
351
+ };
320
352
  }
321
353
 
322
354
  function emptyRuntimeImports() {
@@ -324,6 +356,7 @@ function emptyRuntimeImports() {
324
356
  bySpecifier: new Map(),
325
357
  openclawSdkSpecifiers: new Set(["openclaw/plugin-sdk"]),
326
358
  bareSpecifiers: new Set(),
359
+ requireFiles: new Set(),
327
360
  };
328
361
  }
329
362
 
@@ -331,6 +364,7 @@ async function collectRuntimeImports(pluginRoot) {
331
364
  const bySpecifier = new Map();
332
365
  const openclawSdkSpecifiers = new Set(["openclaw/plugin-sdk"]);
333
366
  const bareSpecifiers = new Set();
367
+ const requireFiles = new Set();
334
368
  for (const filePath of await listSourceFiles(pluginRoot)) {
335
369
  const text = await readFile(filePath, "utf8");
336
370
  for (const entry of parseModuleImports(text)) {
@@ -341,6 +375,10 @@ async function collectRuntimeImports(pluginRoot) {
341
375
  } else {
342
376
  continue;
343
377
  }
378
+ if (entry.require && !requireFiles.has(path.resolve(filePath))) {
379
+ requireFiles.add(path.resolve(filePath));
380
+ requireFiles.add(await realpath(filePath));
381
+ }
344
382
  const names = bySpecifier.get(entry.specifier) ?? new Set();
345
383
  for (const name of entry.names) {
346
384
  names.add(name);
@@ -348,7 +386,7 @@ async function collectRuntimeImports(pluginRoot) {
348
386
  bySpecifier.set(entry.specifier, names);
349
387
  }
350
388
  }
351
- return { bySpecifier, openclawSdkSpecifiers, bareSpecifiers };
389
+ return { bySpecifier, openclawSdkSpecifiers, bareSpecifiers, requireFiles };
352
390
  }
353
391
 
354
392
  async function listSourceFiles(dir) {
@@ -392,9 +430,62 @@ function parseModuleImports(text) {
392
430
  for (const match of text.matchAll(/\bimport\s+["']([^"']+)["']/g)) {
393
431
  entries.push({ specifier: match[1], names: new Set() });
394
432
  }
433
+ for (const { specifier, binding, member } of collectCommonJsRequires(text)) {
434
+ const names = new Set();
435
+ if (binding?.startsWith("{")) {
436
+ for (const part of binding.slice(1, -1).split(",")) {
437
+ const name = part.split(/[:=]/)[0].trim();
438
+ if (isValidExportName(name)) names.add(name);
439
+ }
440
+ } else if (binding) {
441
+ const escaped = binding.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
442
+ for (const access of text.matchAll(new RegExp(`(?<![$\\w])${escaped}\\s*\\.\\s*([$\\w]+)`, "g"))) {
443
+ names.add(access[1]);
444
+ }
445
+ }
446
+ if (member) names.add(member);
447
+ entries.push({ specifier, names, require: true });
448
+ }
395
449
  return entries;
396
450
  }
397
451
 
452
+ export function* collectCommonJsRequires(text) {
453
+ const code = /\/\/[^\r\n]*|\/\*[\s\S]*?(?:\*\/|$)|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|(?<![$\w.])(?:(?:const|let|var)\s+(\{[^{}]*\}|[$A-Z_a-z][$\w]*)\s*=\s*)?require\s*\(\s*["']([^"']+)["']\s*\)(?:\s*\.\s*([$A-Z_a-z][$\w]*))?|[`{}]/g;
454
+ const template = /\\[\s\S]|`|\$\{/g;
455
+ const templateDepths = [];
456
+ let inTemplateText = false;
457
+ let cursor = 0;
458
+ // Skip quoted/comment text, but scan executable template interpolations.
459
+ while (cursor < text.length) {
460
+ const pattern = inTemplateText ? template : code;
461
+ pattern.lastIndex = cursor;
462
+ const match = pattern.exec(text);
463
+ if (!match) break;
464
+ cursor = pattern.lastIndex;
465
+ if (inTemplateText) {
466
+ if (match[0] === "`") {
467
+ templateDepths.pop();
468
+ inTemplateText = false;
469
+ } else if (match[0] === "${") {
470
+ templateDepths[templateDepths.length - 1] = 1;
471
+ inTemplateText = false;
472
+ }
473
+ continue;
474
+ }
475
+ if (match[0] === "`") {
476
+ templateDepths.push(0);
477
+ inTemplateText = true;
478
+ } else if (templateDepths.length && match[0] === "{") {
479
+ templateDepths[templateDepths.length - 1] += 1;
480
+ } else if (templateDepths.length && match[0] === "}") {
481
+ inTemplateText = --templateDepths[templateDepths.length - 1] === 0;
482
+ }
483
+ if (match[2]) {
484
+ yield { specifier: match[2], binding: match[1], member: match[3], index: match.index };
485
+ }
486
+ }
487
+ }
488
+
398
489
  function isTypeOnlyImportOrExport(statement, clause) {
399
490
  return /^\s*import\s+type\b/u.test(statement) || /^\s*export\s+type\b/u.test(statement) || /^\s*type\b/u.test(clause);
400
491
  }
@@ -417,6 +508,7 @@ function parseNamedImports(clause) {
417
508
 
418
509
  function isMockableBareSpecifier(specifier) {
419
510
  return (
511
+ !nodeModule.isBuiltin(specifier) &&
420
512
  !specifier.startsWith(".") &&
421
513
  !specifier.startsWith("/") &&
422
514
  !specifier.startsWith("node:") &&
@@ -429,7 +521,9 @@ function safeModuleFileName(specifier) {
429
521
  return specifier.replace(/[^A-Za-z0-9._-]+/gu, "__");
430
522
  }
431
523
 
432
- function mockLoaderSource({ externalMap, fallbackExternalPath, pluginSdkDir }) {
524
+ function mockLoaderSource({ externalMap, fallbackExternalPath, pluginSdkDir, roots, requireFiles, synchronous }) {
525
+ const asyncKeyword = synchronous ? "" : "async ";
526
+ const awaitKeyword = synchronous ? "" : "await ";
433
527
  return `import { existsSync } from "node:fs";
434
528
  import { readFile } from "node:fs/promises";
435
529
  import { builtinModules, stripTypeScriptTypes } from "node:module";
@@ -439,9 +533,30 @@ import { fileURLToPath, pathToFileURL } from "node:url";
439
533
  const externalMap = new Map(Object.entries(${JSON.stringify(externalMap)}));
440
534
  const fallbackExternalPath = ${JSON.stringify(fallbackExternalPath)};
441
535
  const pluginSdkDir = ${JSON.stringify(pluginSdkDir)};
536
+ const roots = ${JSON.stringify(roots)};
537
+ const requireFiles = new Set(${JSON.stringify(requireFiles)});
442
538
  const builtins = new Set([...builtinModules, ...builtinModules.map((name) => \`node:\${name}\`)]);
539
+ let active;
540
+ let supportsCommonJs = false;
541
+
542
+ export function initialize(data) {
543
+ active = data?.active;
544
+ supportsCommonJs = data?.supportsCommonJs === true;
545
+ }
443
546
 
444
- export async function resolve(specifier, context, nextResolve) {
547
+ function owns(url) {
548
+ if ((active && Atomics.load(active, 0) === 0) || !url?.startsWith("file:")) return false;
549
+ const filePath = fileURLToPath(url);
550
+ return roots.some((root) => {
551
+ const relative = path.relative(root, filePath);
552
+ return relative === "" || (!relative.startsWith(\`..\${path.sep}\`) && relative !== ".." && !path.isAbsolute(relative));
553
+ });
554
+ }
555
+
556
+ export ${asyncKeyword}function resolve(specifier, context, nextResolve) {
557
+ if (!owns(context.parentURL) || builtins.has(specifier) || specifier.startsWith("node:")) {
558
+ return nextResolve(specifier, context);
559
+ }
445
560
  if (specifier === "openclaw/plugin-sdk") {
446
561
  return moduleUrl(path.join(pluginSdkDir, "index.js"));
447
562
  }
@@ -458,7 +573,7 @@ export async function resolve(specifier, context, nextResolve) {
458
573
  return moduleUrl(externalMap.get(specifier));
459
574
  }
460
575
  try {
461
- return await nextResolve(specifier, context);
576
+ return ${awaitKeyword}nextResolve(specifier, context);
462
577
  } catch (error) {
463
578
  const resolved = resolveExtensionless(specifier, context.parentURL);
464
579
  if (resolved) {
@@ -472,11 +587,16 @@ export async function resolve(specifier, context, nextResolve) {
472
587
  }
473
588
 
474
589
  export async function load(url, context, nextLoad) {
590
+ if (!owns(url)) return nextLoad(url, context);
475
591
  if (url.startsWith("file:") && /\\.[cm]?ts$/u.test(fileURLToPath(url))) {
476
592
  const rawSource = await readFile(fileURLToPath(url), "utf8");
477
593
  return { format: "module", source: stripPluginTypeScript(rawSource), shortCircuit: true };
478
594
  }
479
- return nextLoad(url, context);
595
+ const result = await nextLoad(url, context);
596
+ ${synchronous ? "" : `if (!supportsCommonJs && result.format === "commonjs" && requireFiles.has(fileURLToPath(url))) {
597
+ 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.");
598
+ }`}
599
+ return result;
480
600
  }
481
601
 
482
602
  function stripPluginTypeScript(source) {