@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.
- package/CHANGELOG.md +16 -0
- package/README.md +123 -0
- package/package.json +1 -1
- package/src/api.js +7 -0
- package/src/artifacts.js +22 -1
- package/src/capture-api.js +15 -0
- package/src/capture-cli.js +1 -1
- package/src/capture-config.js +10 -7
- package/src/cli.js +3 -1
- package/src/cold-import-readiness.js +3 -3
- package/src/import-loop-profile.js +56 -11
- package/src/inspector.js +110 -45
- package/src/mock-sdk-capture-runner.js +61 -22
- package/src/openclaw-version.js +121 -9
- package/src/process-profile.js +255 -107
- package/src/runtime-capture-report.js +6 -0
- package/src/runtime-profile.js +4 -0
- package/src/sdk-mock.js +133 -13
- package/src/synthetic-entrypoint.js +19 -19
- package/src/synthetic-probes-cli.js +79 -2
- package/src/synthetic-probes.js +201 -34
package/src/process-profile.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
let
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
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 (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
|
137
|
-
return
|
|
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,
|
package/src/runtime-profile.js
CHANGED
|
@@ -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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|