@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/inspector.js
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { execFile } from "node:child_process";
|
|
3
2
|
import { readdir, readFile } from "node:fs/promises";
|
|
4
3
|
import * as nodeModule from "node:module";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
6
|
import { createCaptureApi } from "./capture-api.js";
|
|
9
7
|
import { captureApiOptionsForPlugin } from "./capture-config.js";
|
|
10
8
|
import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js";
|
|
11
9
|
import { buildCompatibilityFixtureReport } from "./fixture-summary.js";
|
|
12
10
|
import { readOpenClawTargetSurface } from "./openclaw-target.js";
|
|
13
11
|
import { prepareOpenClawTarget, resolveOpenClawTargetVersion } from "./openclaw-version.js";
|
|
12
|
+
import { resolveProcessLimits, startOwnedProcess } from "./process-profile.js";
|
|
14
13
|
import { buildCompatibilityReport, buildReport } from "./report.js";
|
|
15
14
|
import { inspectSdkDeprecations } from "./sdk-deprecation-rules.js";
|
|
15
|
+
import { collectCommonJsRequires } from "./sdk-mock.js";
|
|
16
16
|
|
|
17
|
-
const execFileAsync = promisify(execFile);
|
|
18
17
|
const pluginFactoryNames = "defineBundledChannelEntry|defineChannelPluginEntry|createChatChannelPlugin|definePluginEntry";
|
|
19
18
|
// Bundlers emit unbound calls as (0, sdk.factory)(...), including inline require receivers.
|
|
20
19
|
const compiledFactoryCall = new RegExp(String.raw`\(\s*0\s*,\s*(?:require\s*\(\s*(?:"[^"\r\n]*"|'[^'\r\n]*')\s*\)|[$A-Z_a-z][$\w]*)(?:\s*\.\s*[$A-Z_a-z][$\w]*)*\s*\.\s*(${pluginFactoryNames})\s*\)\s*\(`, "dg");
|
|
@@ -185,14 +184,23 @@ export async function captureEntrypoint(entrypoint, options = {}) {
|
|
|
185
184
|
if (options.mockSdk === true) {
|
|
186
185
|
return captureEntrypointWithMockSdk(entrypoint, options);
|
|
187
186
|
}
|
|
187
|
+
if (options.isolateCapture === true) {
|
|
188
|
+
return captureEntrypointInChild(entrypoint, { ...options, mockSdk: false });
|
|
189
|
+
}
|
|
190
|
+
return invokeWithTimeout((signal) => captureInProcess(entrypoint, options, signal), options);
|
|
191
|
+
}
|
|
188
192
|
|
|
193
|
+
async function captureInProcess(entrypoint, options, signal) {
|
|
194
|
+
signal.throwIfAborted();
|
|
189
195
|
const resolvedEntrypoint = path.resolve(options.cwd ?? process.cwd(), entrypoint);
|
|
190
196
|
let module;
|
|
191
197
|
try {
|
|
192
198
|
module = await import(pathToFileURL(resolvedEntrypoint).href);
|
|
193
199
|
} catch (error) {
|
|
200
|
+
signal.throwIfAborted();
|
|
194
201
|
throw classifyCapturePhaseError(error, "entrypoint-import-error");
|
|
195
202
|
}
|
|
203
|
+
signal.throwIfAborted();
|
|
196
204
|
const register = findRegisterExport(module);
|
|
197
205
|
|
|
198
206
|
if (!register) {
|
|
@@ -207,13 +215,17 @@ export async function captureEntrypoint(entrypoint, options = {}) {
|
|
|
207
215
|
pluginRoot: options.pluginRoot
|
|
208
216
|
? path.resolve(options.cwd ?? process.cwd(), options.pluginRoot)
|
|
209
217
|
: path.dirname(resolvedEntrypoint),
|
|
218
|
+
signal,
|
|
210
219
|
});
|
|
220
|
+
signal.throwIfAborted();
|
|
211
221
|
const api = createCaptureApi(apiOptions);
|
|
212
222
|
try {
|
|
213
223
|
await register(api);
|
|
214
224
|
} catch (error) {
|
|
225
|
+
signal.throwIfAborted();
|
|
215
226
|
throw classifyCapturePhaseError(error, "registration-execution-error");
|
|
216
227
|
}
|
|
228
|
+
signal.throwIfAborted();
|
|
217
229
|
const result = {
|
|
218
230
|
status: "captured",
|
|
219
231
|
entrypoint: resolvedEntrypoint,
|
|
@@ -226,60 +238,68 @@ export async function captureEntrypoint(entrypoint, options = {}) {
|
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
export async function captureEntrypointWithMockSdk(entrypoint, options = {}) {
|
|
241
|
+
return captureEntrypointInChild(entrypoint, { ...options, mockSdk: true });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function captureEntrypointInChild(entrypoint, options) {
|
|
245
|
+
const label = options.mockSdk ? "Mock SDK" : "Real SDK";
|
|
229
246
|
const runnerPath = fileURLToPath(new URL("./mock-sdk-capture-runner.js", import.meta.url));
|
|
230
247
|
const payload = {
|
|
231
248
|
entrypoint,
|
|
249
|
+
mockSdk: options.mockSdk,
|
|
232
250
|
cwd: options.cwd ?? process.cwd(),
|
|
233
251
|
pluginRoot: options.pluginRoot,
|
|
234
252
|
apiOptions: options.apiOptions,
|
|
253
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
235
254
|
};
|
|
255
|
+
const { result } = startOwnedProcess({
|
|
256
|
+
command: process.execPath,
|
|
257
|
+
args: ["--no-warnings", ...(options.mockSdk ? ["--preserve-symlinks"] : []), runnerPath, JSON.stringify(payload)],
|
|
258
|
+
cwd: options.cwd ?? process.cwd(),
|
|
259
|
+
env: { ...process.env, ...options.env },
|
|
260
|
+
timeoutMs: options.timeoutMs,
|
|
261
|
+
killGraceMs: options.killGraceMs,
|
|
262
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
263
|
+
signal: options.signal,
|
|
264
|
+
}, "CAPTURE");
|
|
265
|
+
const outcome = await result;
|
|
266
|
+
if (outcome.exitCode !== 0 || outcome.outputTruncated) {
|
|
267
|
+
const message = outcome.cancelled ? `${label} capture cancelled`
|
|
268
|
+
: outcome.outputTruncated ? `${label} capture output exceeded its byte limit`
|
|
269
|
+
: `${label} capture child failed`;
|
|
270
|
+
const { error: childError, ...details } = outcome;
|
|
271
|
+
throw classifyChildCaptureError(Object.assign(childError ?? new Error(message), details), options.mockSdk);
|
|
272
|
+
}
|
|
236
273
|
try {
|
|
237
|
-
|
|
238
|
-
process.execPath,
|
|
239
|
-
["--no-warnings", "--preserve-symlinks", runnerPath, JSON.stringify(payload)],
|
|
240
|
-
{
|
|
241
|
-
cwd: options.cwd ?? process.cwd(),
|
|
242
|
-
env: {
|
|
243
|
-
...process.env,
|
|
244
|
-
...(options.env ?? {}),
|
|
245
|
-
},
|
|
246
|
-
maxBuffer: 1024 * 1024 * 10,
|
|
247
|
-
},
|
|
248
|
-
);
|
|
249
|
-
return JSON.parse(stdout);
|
|
274
|
+
return JSON.parse(outcome.stdout);
|
|
250
275
|
} catch (error) {
|
|
251
|
-
|
|
252
|
-
if (captured) {
|
|
253
|
-
return captured;
|
|
254
|
-
}
|
|
255
|
-
throw classifyMockSdkCaptureError(error);
|
|
276
|
+
throw classifyChildCaptureError(error, options.mockSdk);
|
|
256
277
|
}
|
|
257
278
|
}
|
|
258
279
|
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
|
|
280
|
+
export function classifyMockSdkCaptureError(error) {
|
|
281
|
+
return classifyChildCaptureError(error, true);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function classifyChildCaptureError(error, mockSdk) {
|
|
285
|
+
const label = mockSdk ? "Mock SDK" : "Real SDK";
|
|
286
|
+
const fallbackClass = mockSdk ? "mock-sdk-capture-error" : "capture-error";
|
|
287
|
+
if (error?.timedOut === true) {
|
|
288
|
+
return enrichCaptureError(error, {
|
|
289
|
+
message: `${label} capture timed out after ${error.timeoutMs}ms`,
|
|
290
|
+
failureClass: "capture-timeout",
|
|
291
|
+
});
|
|
262
292
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
typeof parsed.status === "string" &&
|
|
269
|
-
Array.isArray(parsed.captured)
|
|
270
|
-
) {
|
|
271
|
-
return parsed;
|
|
272
|
-
}
|
|
273
|
-
} catch {
|
|
274
|
-
return null;
|
|
293
|
+
if (error?.cancelled || error?.outputTruncated) {
|
|
294
|
+
return enrichCaptureError(error, {
|
|
295
|
+
message: error.message,
|
|
296
|
+
failureClass: fallbackClass,
|
|
297
|
+
});
|
|
275
298
|
}
|
|
276
|
-
return null;
|
|
277
|
-
}
|
|
278
299
|
|
|
279
|
-
export function classifyMockSdkCaptureError(error) {
|
|
280
300
|
const rawMessage = [error?.stderr, error?.stdout, error?.message].filter(Boolean).join("\n");
|
|
281
301
|
const missingExport = rawMessage.match(/does not provide an export named ['"]([^'"]+)['"]/)?.[1];
|
|
282
|
-
if (missingExport) {
|
|
302
|
+
if (mockSdk && missingExport) {
|
|
283
303
|
return enrichCaptureError(error, {
|
|
284
304
|
message: `Mock SDK import failed: openclaw/plugin-sdk is missing export ${missingExport}`,
|
|
285
305
|
failureClass: "missing-sdk-export",
|
|
@@ -290,9 +310,9 @@ export function classifyMockSdkCaptureError(error) {
|
|
|
290
310
|
const missingModule =
|
|
291
311
|
rawMessage.match(/Cannot find (?:package|module) ['"]([^'"]*openclaw\/plugin-sdk[^'"]*)['"]/)?.[1] ??
|
|
292
312
|
rawMessage.match(/Package subpath ['"](\.\/plugin-sdk\/[^'"]+)['"]/)?.[1];
|
|
293
|
-
if (
|
|
313
|
+
if (mockSdk && missingModule) {
|
|
294
314
|
return enrichCaptureError(error, {
|
|
295
|
-
message: `Mock SDK import failed: ${missingModule
|
|
315
|
+
message: `Mock SDK import failed: ${missingModule}`,
|
|
296
316
|
failureClass: "missing-sdk-module",
|
|
297
317
|
missingModule,
|
|
298
318
|
});
|
|
@@ -301,17 +321,52 @@ export function classifyMockSdkCaptureError(error) {
|
|
|
301
321
|
const failureClass = rawMessage.match(/\[plugin-inspector:([^\]]+)\]/)?.[1];
|
|
302
322
|
if (failureClass) {
|
|
303
323
|
return enrichCaptureError(error, {
|
|
304
|
-
message: firstMeaningfulErrorLine(rawMessage.replace(/\[plugin-inspector:[^\]]+\]/, "")) ??
|
|
324
|
+
message: firstMeaningfulErrorLine(rawMessage.replace(/\[plugin-inspector:[^\]]+\]/, "")) ?? `${label} capture failed`,
|
|
305
325
|
failureClass,
|
|
306
326
|
});
|
|
307
327
|
}
|
|
328
|
+
if (mockSdk && rawMessage.includes("openclaw/plugin-sdk")) {
|
|
329
|
+
return enrichCaptureError(error, {
|
|
330
|
+
message: "Mock SDK import failed: openclaw/plugin-sdk module could not be resolved",
|
|
331
|
+
failureClass: "missing-sdk-module",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
308
334
|
|
|
309
335
|
return enrichCaptureError(error, {
|
|
310
|
-
message: firstMeaningfulErrorLine(rawMessage) ??
|
|
311
|
-
failureClass:
|
|
336
|
+
message: firstMeaningfulErrorLine(rawMessage) ?? `${label} capture failed`,
|
|
337
|
+
failureClass: fallbackClass,
|
|
312
338
|
});
|
|
313
339
|
}
|
|
314
340
|
|
|
341
|
+
function invokeWithTimeout(invoke, options) {
|
|
342
|
+
const { timeoutMs } = resolveProcessLimits(options, "CAPTURE");
|
|
343
|
+
const controller = new AbortController();
|
|
344
|
+
let rejectAborted;
|
|
345
|
+
const aborted = new Promise((_, reject) => { rejectAborted = reject; });
|
|
346
|
+
const onAbort = () => rejectAborted(controller.signal.reason);
|
|
347
|
+
const onCancel = () => controller.abort(classifyCapturePhaseError(
|
|
348
|
+
new Error("In-process capture cancelled"), "capture-error",
|
|
349
|
+
));
|
|
350
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
351
|
+
const timeoutId = setTimeout(() => controller.abort(classifyCapturePhaseError(
|
|
352
|
+
new Error(`In-process capture timed out after ${timeoutMs}ms`), "capture-timeout",
|
|
353
|
+
)), timeoutMs);
|
|
354
|
+
// Observe late settlement, but stop inspector-owned phases after the deadline.
|
|
355
|
+
// Arbitrary plugin JavaScript is only preemptable in the supervised CLI child.
|
|
356
|
+
const run = Promise.resolve().then(() => {
|
|
357
|
+
controller.signal.throwIfAborted();
|
|
358
|
+
return invoke(controller.signal);
|
|
359
|
+
});
|
|
360
|
+
const result = Promise.race([run, aborted]).finally(() => {
|
|
361
|
+
clearTimeout(timeoutId);
|
|
362
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
363
|
+
options.signal?.removeEventListener("abort", onCancel);
|
|
364
|
+
});
|
|
365
|
+
options.signal?.addEventListener("abort", onCancel, { once: true });
|
|
366
|
+
if (options.signal?.aborted) onCancel();
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
315
370
|
export function classifyCapturePhaseError(error, failureClass) {
|
|
316
371
|
return enrichCaptureError(error, {
|
|
317
372
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -414,6 +469,16 @@ function collectSdkImports(text, filePath) {
|
|
|
414
469
|
ref: `${filePath}:${line}`,
|
|
415
470
|
});
|
|
416
471
|
}
|
|
472
|
+
for (const { specifier, index } of collectCommonJsRequires(text)) {
|
|
473
|
+
if (specifier !== "openclaw/plugin-sdk" && !specifier.startsWith("openclaw/plugin-sdk/")) continue;
|
|
474
|
+
const line = lineForOffset(text, index);
|
|
475
|
+
details.push({
|
|
476
|
+
specifier,
|
|
477
|
+
file: filePath,
|
|
478
|
+
line,
|
|
479
|
+
ref: `${filePath}:${line}`,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
417
482
|
return details.sort((left, right) => left.line - right.line || left.specifier.localeCompare(right.specifier));
|
|
418
483
|
}
|
|
419
484
|
|
|
@@ -1,37 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { rmSync } from "node:fs";
|
|
3
3
|
import { mkdtemp } from "node:fs/promises";
|
|
4
|
-
import { register } from "node:module";
|
|
5
4
|
import os from "node:os";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { writeArtifacts } from "./artifacts.js";
|
|
8
8
|
import { createCaptureApi } from "./capture-api.js";
|
|
9
9
|
import { captureApiOptionsForPlugin } from "./capture-config.js";
|
|
10
|
-
import {
|
|
10
|
+
import { createCappedCollector, resolveProcessLimits } from "./process-profile.js";
|
|
11
|
+
import { createMockSdkPackage, installMockSdkLoader } from "./sdk-mock.js";
|
|
11
12
|
|
|
12
13
|
const options = JSON.parse(process.argv[2] ?? "{}");
|
|
13
14
|
let activeOutputCapture = null;
|
|
14
15
|
|
|
15
16
|
try {
|
|
16
17
|
const result = await run(options);
|
|
17
|
-
|
|
18
|
+
const json = `${JSON.stringify(result, null, 2)}\n`;
|
|
19
|
+
const { maxOutputBytes } = resolveProcessLimits(options, options.syntheticProbes ? "PROBE" : "CAPTURE");
|
|
20
|
+
if (Buffer.byteLength(json) > maxOutputBytes) {
|
|
21
|
+
const label = options.syntheticProbes ? "Synthetic probe" : `${options.mockSdk === false ? "Real SDK" : "Mock SDK"} capture`;
|
|
22
|
+
throw new Error(`${label} result exceeded its ${maxOutputBytes}-byte limit`);
|
|
23
|
+
}
|
|
24
|
+
if (options.outputPath) {
|
|
25
|
+
await writeArtifacts([{ path: options.outputPath, content: json }]);
|
|
26
|
+
} else {
|
|
27
|
+
await writeRunnerStdout(json);
|
|
28
|
+
}
|
|
29
|
+
process.exit(0);
|
|
18
30
|
} catch (error) {
|
|
19
31
|
if (error.failureClass) {
|
|
20
|
-
writeRunnerStderr(`[plugin-inspector:${error.failureClass}]\n`);
|
|
32
|
+
await writeRunnerStderr(`[plugin-inspector:${error.failureClass}]\n`);
|
|
21
33
|
}
|
|
22
|
-
writeRunnerStderr(`${error.stack ?? error.message}\n`);
|
|
23
|
-
process.
|
|
34
|
+
await writeRunnerStderr(`${options.outputPath ? error.message : (error.stack ?? error.message)}\n`);
|
|
35
|
+
process.exit(1);
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
async function run(options) {
|
|
27
39
|
const entrypoint = path.resolve(options.cwd ?? process.cwd(), options.entrypoint);
|
|
28
40
|
const pluginRoot = path.resolve(options.cwd ?? process.cwd(), options.pluginRoot ?? path.dirname(entrypoint));
|
|
41
|
+
if (options.mockSdk === false) {
|
|
42
|
+
return await captureLinkedEntrypoint(entrypoint, { ...options, pluginRoot });
|
|
43
|
+
}
|
|
29
44
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-mock-sdk-"));
|
|
30
45
|
|
|
31
46
|
cleanupTempDirOnExit(workspace);
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
const mockPackage = await createMockSdkPackage(workspace, { pluginRoot });
|
|
48
|
+
const stopLoader = await installMockSdkLoader(mockPackage);
|
|
49
|
+
try {
|
|
50
|
+
return await captureLinkedEntrypoint(entrypoint, { ...options, pluginRoot });
|
|
51
|
+
} finally {
|
|
52
|
+
stopLoader();
|
|
53
|
+
}
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
function cleanupTempDirOnExit(dir) {
|
|
@@ -55,14 +74,15 @@ async function captureLinkedEntrypoint(entrypoint, options) {
|
|
|
55
74
|
|
|
56
75
|
if (!register) {
|
|
57
76
|
await drainAsyncOutput();
|
|
58
|
-
return
|
|
77
|
+
return finishCapture(
|
|
59
78
|
{
|
|
60
79
|
status: "no-register-export",
|
|
61
|
-
entrypoint: options.entrypoint,
|
|
62
|
-
mockSdk:
|
|
80
|
+
entrypoint: options.mockSdk === false ? entrypoint : options.entrypoint,
|
|
81
|
+
mockSdk: options.mockSdk !== false,
|
|
63
82
|
captured: [],
|
|
64
83
|
},
|
|
65
84
|
outputCapture,
|
|
85
|
+
options,
|
|
66
86
|
);
|
|
67
87
|
}
|
|
68
88
|
|
|
@@ -80,13 +100,22 @@ async function captureLinkedEntrypoint(entrypoint, options) {
|
|
|
80
100
|
|
|
81
101
|
const result = {
|
|
82
102
|
status: "captured",
|
|
83
|
-
entrypoint: options.entrypoint,
|
|
84
|
-
mockSdk:
|
|
103
|
+
entrypoint: options.mockSdk === false ? entrypoint : options.entrypoint,
|
|
104
|
+
mockSdk: options.mockSdk !== false,
|
|
85
105
|
captured: api.getCapturedContracts(),
|
|
86
106
|
};
|
|
87
107
|
if (apiOptions?.retainHandlers === true) {
|
|
88
108
|
result.retained = api.getRetainedContracts();
|
|
89
109
|
}
|
|
110
|
+
return finishCapture(result, outputCapture, options);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function finishCapture(result, outputCapture, options) {
|
|
114
|
+
if (options.syntheticProbes) {
|
|
115
|
+
const { runCapturedSyntheticProbes } = await import("./synthetic-probes.js");
|
|
116
|
+
result = await runCapturedSyntheticProbes(result, options);
|
|
117
|
+
await drainAsyncOutput();
|
|
118
|
+
}
|
|
90
119
|
return withProcessOutput(result, outputCapture);
|
|
91
120
|
}
|
|
92
121
|
|
|
@@ -124,18 +153,18 @@ function findRegisterExport(module) {
|
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
function installProcessOutputCapture() {
|
|
127
|
-
const
|
|
128
|
-
const
|
|
156
|
+
const stdout = createCappedCollector(1024 * 1024);
|
|
157
|
+
const stderr = createCappedCollector(1024 * 1024);
|
|
129
158
|
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
130
159
|
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
131
160
|
|
|
132
161
|
process.stdout.write = (chunk, encoding, callback) => {
|
|
133
|
-
|
|
162
|
+
stdout.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), typeof encoding === "string" ? encoding : "utf8"));
|
|
134
163
|
invokeWriteCallback(encoding, callback);
|
|
135
164
|
return true;
|
|
136
165
|
};
|
|
137
166
|
process.stderr.write = (chunk, encoding, callback) => {
|
|
138
|
-
|
|
167
|
+
stderr.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), typeof encoding === "string" ? encoding : "utf8"));
|
|
139
168
|
invokeWriteCallback(encoding, callback);
|
|
140
169
|
return true;
|
|
141
170
|
};
|
|
@@ -143,8 +172,8 @@ function installProcessOutputCapture() {
|
|
|
143
172
|
return {
|
|
144
173
|
originalStdoutWrite,
|
|
145
174
|
originalStderrWrite,
|
|
146
|
-
stdout: () =>
|
|
147
|
-
stderr: () =>
|
|
175
|
+
stdout: () => stdout.text(),
|
|
176
|
+
stderr: () => stderr.text(),
|
|
148
177
|
};
|
|
149
178
|
}
|
|
150
179
|
|
|
@@ -162,9 +191,19 @@ async function drainAsyncOutput() {
|
|
|
162
191
|
}
|
|
163
192
|
|
|
164
193
|
function writeRunnerStdout(text) {
|
|
165
|
-
|
|
194
|
+
const write = activeOutputCapture?.originalStdoutWrite ?? process.stdout.write.bind(process.stdout);
|
|
195
|
+
return flushWrite(write, text);
|
|
166
196
|
}
|
|
167
197
|
|
|
168
198
|
function writeRunnerStderr(text) {
|
|
169
|
-
|
|
199
|
+
const write = activeOutputCapture?.originalStderrWrite ?? process.stderr.write.bind(process.stderr);
|
|
200
|
+
return flushWrite(write, text);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The runner exits deliberately to shed plugin timers, but only after the
|
|
204
|
+
// complete protocol response has reached its pipe.
|
|
205
|
+
function flushWrite(write, text) {
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
write(text, (error) => error ? reject(error) : resolve());
|
|
208
|
+
});
|
|
170
209
|
}
|
package/src/openclaw-version.js
CHANGED
|
@@ -8,6 +8,9 @@ import { x as extractTar } from "tar";
|
|
|
8
8
|
import { readOpenClawTargetSurface } from "./openclaw-target.js";
|
|
9
9
|
|
|
10
10
|
const defaultRegistryUrl = "https://registry.npmjs.org";
|
|
11
|
+
const defaultFetchTimeoutMs = 30_000;
|
|
12
|
+
const defaultMaxArchiveBytes = 256 * 1024 * 1024;
|
|
13
|
+
const defaultMaxMetadataBytes = 16 * 1024 * 1024;
|
|
11
14
|
const supportedTags = new Set(["latest", "beta"]);
|
|
12
15
|
const downloadUrls = new WeakMap();
|
|
13
16
|
|
|
@@ -25,8 +28,8 @@ export async function resolveOpenClawTargetVersion(requestedVersion, options = {
|
|
|
25
28
|
let distTag = null;
|
|
26
29
|
|
|
27
30
|
if (supportedTags.has(requested)) {
|
|
28
|
-
const
|
|
29
|
-
version =
|
|
31
|
+
const distTags = await fetchJson(`${registryUrl}/-/package/openclaw/dist-tags`, fetchImpl, options);
|
|
32
|
+
version = distTags?.[requested];
|
|
30
33
|
if (typeof version !== "string" || version.length === 0) {
|
|
31
34
|
throw new Error(`OpenClaw npm dist-tag ${requested} did not resolve to an exact version`);
|
|
32
35
|
}
|
|
@@ -38,7 +41,7 @@ export async function resolveOpenClawTargetVersion(requestedVersion, options = {
|
|
|
38
41
|
throw new Error("--openclaw-version must be latest, beta, or an exact OpenClaw version");
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
const versionMetadata = await fetchJson(`${registryUrl}/openclaw/${encodeURIComponent(version)}`, fetchImpl);
|
|
44
|
+
const versionMetadata = await fetchJson(`${registryUrl}/openclaw/${encodeURIComponent(version)}`, fetchImpl, options);
|
|
42
45
|
if (versionMetadata.version !== version || typeof versionMetadata.dist?.tarball !== "string") {
|
|
43
46
|
throw new Error(`OpenClaw npm metadata for ${version} is incomplete`);
|
|
44
47
|
}
|
|
@@ -137,11 +140,12 @@ export function satisfiesOpenClawCompatibilityRange({ targetVersion, eligibility
|
|
|
137
140
|
|
|
138
141
|
async function preparePackageArchive(resolvedTarget, options) {
|
|
139
142
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
140
|
-
const response = await fetchImpl
|
|
143
|
+
const response = await fetchWithTimeout(fetchImpl, downloadUrlFor(resolvedTarget), {}, options, "npm archive");
|
|
141
144
|
if (!response.ok) {
|
|
145
|
+
await cancelBody(response.body);
|
|
142
146
|
throw new Error(`failed to download OpenClaw ${resolvedTarget.version}: HTTP ${response.status}`);
|
|
143
147
|
}
|
|
144
|
-
const archive =
|
|
148
|
+
const archive = await readLimitedBody(response, maxArchiveBytes(options), "npm archive");
|
|
145
149
|
verifyArchive(archive, resolvedTarget.source);
|
|
146
150
|
|
|
147
151
|
await mkdir(path.dirname(options.targetDir), { recursive: true });
|
|
@@ -191,10 +195,118 @@ function verifyArchive(archive, source) {
|
|
|
191
195
|
throw new Error("OpenClaw npm archive has no supported integrity metadata");
|
|
192
196
|
}
|
|
193
197
|
|
|
194
|
-
async function fetchJson(url, fetchImpl) {
|
|
195
|
-
const response = await
|
|
196
|
-
|
|
197
|
-
|
|
198
|
+
async function fetchJson(url, fetchImpl, options = {}) {
|
|
199
|
+
const response = await fetchWithTimeout(
|
|
200
|
+
fetchImpl,
|
|
201
|
+
url,
|
|
202
|
+
{ headers: { accept: "application/json" } },
|
|
203
|
+
options,
|
|
204
|
+
"npm metadata",
|
|
205
|
+
);
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
await cancelBody(response.body);
|
|
208
|
+
throw new Error(`failed to resolve OpenClaw npm metadata: HTTP ${response.status}`);
|
|
209
|
+
}
|
|
210
|
+
const body = await readLimitedBody(response, maxMetadataBytes(options), "npm metadata");
|
|
211
|
+
return JSON.parse(body.toString("utf8"));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function fetchWithTimeout(fetchImpl, url, init, options, what) {
|
|
215
|
+
try {
|
|
216
|
+
return await fetchImpl(url, { ...init, signal: AbortSignal.timeout(fetchTimeoutMs(options)) });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw mapTargetFetchError(error, what);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function readLimitedBody(response, maxBytes, what) {
|
|
223
|
+
const declared = Number(response.headers.get("content-length"));
|
|
224
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
225
|
+
await cancelBody(response.body);
|
|
226
|
+
throw targetDownloadLimitError(what, maxBytes);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let reader;
|
|
230
|
+
try {
|
|
231
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
232
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
233
|
+
if (buffer.length > maxBytes) throw targetDownloadLimitError(what, maxBytes);
|
|
234
|
+
return buffer;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
reader = response.body.getReader();
|
|
238
|
+
const chunks = [];
|
|
239
|
+
let received = 0;
|
|
240
|
+
while (true) {
|
|
241
|
+
const { done, value } = await reader.read();
|
|
242
|
+
if (done) break;
|
|
243
|
+
received += value.byteLength;
|
|
244
|
+
if (received > maxBytes) {
|
|
245
|
+
try {
|
|
246
|
+
await reader.cancel();
|
|
247
|
+
} catch {}
|
|
248
|
+
throw targetDownloadLimitError(what, maxBytes);
|
|
249
|
+
}
|
|
250
|
+
chunks.push(value);
|
|
251
|
+
}
|
|
252
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
253
|
+
} catch (error) {
|
|
254
|
+
throw mapTargetFetchError(error, what);
|
|
255
|
+
} finally {
|
|
256
|
+
reader?.releaseLock();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function cancelBody(body) {
|
|
261
|
+
try {
|
|
262
|
+
await body?.cancel?.();
|
|
263
|
+
} catch {}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function fetchTimeoutMs(options) {
|
|
267
|
+
const timeout = positiveInteger(
|
|
268
|
+
options.fetchTimeoutMs ?? process.env.PLUGIN_INSPECTOR_TARGET_FETCH_TIMEOUT_MS,
|
|
269
|
+
defaultFetchTimeoutMs,
|
|
270
|
+
);
|
|
271
|
+
// Node clamps overflowing timer delays to 1ms instead of honoring the budget.
|
|
272
|
+
return timeout <= 2_147_483_647 ? timeout : defaultFetchTimeoutMs;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function maxArchiveBytes(options) {
|
|
276
|
+
return positiveInteger(options.maxArchiveBytes ?? process.env.PLUGIN_INSPECTOR_TARGET_ARCHIVE_MAX_BYTES, defaultMaxArchiveBytes);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function maxMetadataBytes(options) {
|
|
280
|
+
return positiveInteger(options.maxMetadataBytes ?? process.env.PLUGIN_INSPECTOR_TARGET_METADATA_MAX_BYTES, defaultMaxMetadataBytes);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function positiveInteger(value, fallback) {
|
|
284
|
+
const parsed = Number(value);
|
|
285
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mapTargetFetchError(error, what) {
|
|
289
|
+
if (error?.failureClass) return error;
|
|
290
|
+
if (isTimeoutError(error)) {
|
|
291
|
+
const wrapped = new Error(`OpenClaw ${what} download timed out`);
|
|
292
|
+
wrapped.failureClass = "target-download-timeout";
|
|
293
|
+
wrapped.cause = error;
|
|
294
|
+
return wrapped;
|
|
295
|
+
}
|
|
296
|
+
return error;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isTimeoutError(error) {
|
|
300
|
+
for (let current = error; current; current = current.cause) {
|
|
301
|
+
if (current.name === "TimeoutError" || current.name === "AbortError") return true;
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function targetDownloadLimitError(what, maxBytes) {
|
|
307
|
+
const error = new Error(`OpenClaw ${what} exceeds the ${maxBytes} byte download limit`);
|
|
308
|
+
error.failureClass = "target-download-too-large";
|
|
309
|
+
return error;
|
|
198
310
|
}
|
|
199
311
|
|
|
200
312
|
function cacheKeyFor(target) {
|