@openclaw/plugin-inspector 0.3.23 → 0.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/inspector.js CHANGED
@@ -1,20 +1,22 @@
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);
17
+ const pluginFactoryNames = "defineBundledChannelEntry|defineChannelPluginEntry|createChatChannelPlugin|definePluginEntry";
18
+ // Bundlers emit unbound calls as (0, sdk.factory)(...), including inline require receivers.
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");
18
20
  const registrationEquivalents = new Map([
19
21
  ["registerChannel", new Set(["createChatChannelPlugin", "defineBundledChannelEntry", "defineChannelPluginEntry", "registerChannel"])],
20
22
  ]);
@@ -164,10 +166,8 @@ export function inspectSourceText(text, filePath = "source.js") {
164
166
  const hooks = collectDetailedMatches(searchableText, /\bapi\.on\(\s*["'`]([^"'`]+)["'`]/g, filePath, "name");
165
167
  const registrations = [
166
168
  ...collectDetailedMatches(searchableText, /\bapi\.(register[A-Za-z0-9]+)\s*\(/g, filePath, "name"),
167
- ...collectDetailedMatches(searchableText, /\b(defineBundledChannelEntry)\s*\(/g, filePath, "name"),
168
- ...collectDetailedMatches(searchableText, /\b(defineChannelPluginEntry)\s*\(/g, filePath, "name"),
169
- ...collectDetailedMatches(searchableText, /\b(createChatChannelPlugin)\s*\(/g, filePath, "name"),
170
- ...collectDetailedMatches(searchableText, /\b(definePluginEntry)\s*\(/g, filePath, "name"),
169
+ ...collectDetailedMatches(searchableText, new RegExp(String.raw`\b(${pluginFactoryNames})\s*\(`, "g"), filePath, "name"),
170
+ ...collectDetailedMatches(searchableText, compiledFactoryCall, filePath, "name"),
171
171
  ];
172
172
  const sdkImports = collectSdkImports(searchableText, filePath);
173
173
  const sdkDeprecations = inspectSdkDeprecations(searchableText, filePath);
@@ -184,14 +184,23 @@ export async function captureEntrypoint(entrypoint, options = {}) {
184
184
  if (options.mockSdk === true) {
185
185
  return captureEntrypointWithMockSdk(entrypoint, options);
186
186
  }
187
+ if (options.isolateCapture === true) {
188
+ return captureEntrypointInChild(entrypoint, { ...options, mockSdk: false });
189
+ }
190
+ return invokeWithTimeout((signal) => captureInProcess(entrypoint, options, signal), options);
191
+ }
187
192
 
193
+ async function captureInProcess(entrypoint, options, signal) {
194
+ signal.throwIfAborted();
188
195
  const resolvedEntrypoint = path.resolve(options.cwd ?? process.cwd(), entrypoint);
189
196
  let module;
190
197
  try {
191
198
  module = await import(pathToFileURL(resolvedEntrypoint).href);
192
199
  } catch (error) {
200
+ signal.throwIfAborted();
193
201
  throw classifyCapturePhaseError(error, "entrypoint-import-error");
194
202
  }
203
+ signal.throwIfAborted();
195
204
  const register = findRegisterExport(module);
196
205
 
197
206
  if (!register) {
@@ -206,13 +215,17 @@ export async function captureEntrypoint(entrypoint, options = {}) {
206
215
  pluginRoot: options.pluginRoot
207
216
  ? path.resolve(options.cwd ?? process.cwd(), options.pluginRoot)
208
217
  : path.dirname(resolvedEntrypoint),
218
+ signal,
209
219
  });
220
+ signal.throwIfAborted();
210
221
  const api = createCaptureApi(apiOptions);
211
222
  try {
212
223
  await register(api);
213
224
  } catch (error) {
225
+ signal.throwIfAborted();
214
226
  throw classifyCapturePhaseError(error, "registration-execution-error");
215
227
  }
228
+ signal.throwIfAborted();
216
229
  const result = {
217
230
  status: "captured",
218
231
  entrypoint: resolvedEntrypoint,
@@ -225,60 +238,68 @@ export async function captureEntrypoint(entrypoint, options = {}) {
225
238
  }
226
239
 
227
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";
228
246
  const runnerPath = fileURLToPath(new URL("./mock-sdk-capture-runner.js", import.meta.url));
229
247
  const payload = {
230
248
  entrypoint,
249
+ mockSdk: options.mockSdk,
231
250
  cwd: options.cwd ?? process.cwd(),
232
251
  pluginRoot: options.pluginRoot,
233
252
  apiOptions: options.apiOptions,
253
+ maxOutputBytes: options.maxOutputBytes,
234
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
+ }
235
273
  try {
236
- const { stdout } = await execFileAsync(
237
- process.execPath,
238
- ["--no-warnings", "--preserve-symlinks", runnerPath, JSON.stringify(payload)],
239
- {
240
- cwd: options.cwd ?? process.cwd(),
241
- env: {
242
- ...process.env,
243
- ...(options.env ?? {}),
244
- },
245
- maxBuffer: 1024 * 1024 * 10,
246
- },
247
- );
248
- return JSON.parse(stdout);
274
+ return JSON.parse(outcome.stdout);
249
275
  } catch (error) {
250
- const captured = parseCaptureResultFromStdout(error?.stdout);
251
- if (captured) {
252
- return captured;
253
- }
254
- throw classifyMockSdkCaptureError(error);
276
+ throw classifyChildCaptureError(error, options.mockSdk);
255
277
  }
256
278
  }
257
279
 
258
- function parseCaptureResultFromStdout(stdout) {
259
- if (!stdout) {
260
- return null;
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
+ });
261
292
  }
262
- try {
263
- const parsed = JSON.parse(stdout);
264
- if (
265
- parsed &&
266
- typeof parsed === "object" &&
267
- typeof parsed.status === "string" &&
268
- Array.isArray(parsed.captured)
269
- ) {
270
- return parsed;
271
- }
272
- } catch {
273
- return null;
293
+ if (error?.cancelled || error?.outputTruncated) {
294
+ return enrichCaptureError(error, {
295
+ message: error.message,
296
+ failureClass: fallbackClass,
297
+ });
274
298
  }
275
- return null;
276
- }
277
299
 
278
- export function classifyMockSdkCaptureError(error) {
279
300
  const rawMessage = [error?.stderr, error?.stdout, error?.message].filter(Boolean).join("\n");
280
301
  const missingExport = rawMessage.match(/does not provide an export named ['"]([^'"]+)['"]/)?.[1];
281
- if (missingExport) {
302
+ if (mockSdk && missingExport) {
282
303
  return enrichCaptureError(error, {
283
304
  message: `Mock SDK import failed: openclaw/plugin-sdk is missing export ${missingExport}`,
284
305
  failureClass: "missing-sdk-export",
@@ -289,9 +310,9 @@ export function classifyMockSdkCaptureError(error) {
289
310
  const missingModule =
290
311
  rawMessage.match(/Cannot find (?:package|module) ['"]([^'"]*openclaw\/plugin-sdk[^'"]*)['"]/)?.[1] ??
291
312
  rawMessage.match(/Package subpath ['"](\.\/plugin-sdk\/[^'"]+)['"]/)?.[1];
292
- if (missingModule || rawMessage.includes("openclaw/plugin-sdk")) {
313
+ if (mockSdk && missingModule) {
293
314
  return enrichCaptureError(error, {
294
- message: `Mock SDK import failed: ${missingModule ?? "openclaw/plugin-sdk module could not be resolved"}`,
315
+ message: `Mock SDK import failed: ${missingModule}`,
295
316
  failureClass: "missing-sdk-module",
296
317
  missingModule,
297
318
  });
@@ -300,17 +321,52 @@ export function classifyMockSdkCaptureError(error) {
300
321
  const failureClass = rawMessage.match(/\[plugin-inspector:([^\]]+)\]/)?.[1];
301
322
  if (failureClass) {
302
323
  return enrichCaptureError(error, {
303
- message: firstMeaningfulErrorLine(rawMessage.replace(/\[plugin-inspector:[^\]]+\]/, "")) ?? "Mock SDK capture failed",
324
+ message: firstMeaningfulErrorLine(rawMessage.replace(/\[plugin-inspector:[^\]]+\]/, "")) ?? `${label} capture failed`,
304
325
  failureClass,
305
326
  });
306
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
+ }
307
334
 
308
335
  return enrichCaptureError(error, {
309
- message: firstMeaningfulErrorLine(rawMessage) ?? "Mock SDK capture failed",
310
- failureClass: "mock-sdk-capture-error",
336
+ message: firstMeaningfulErrorLine(rawMessage) ?? `${label} capture failed`,
337
+ failureClass: fallbackClass,
311
338
  });
312
339
  }
313
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
+
314
370
  export function classifyCapturePhaseError(error, failureClass) {
315
371
  return enrichCaptureError(error, {
316
372
  message: error instanceof Error ? error.message : String(error),
@@ -373,7 +429,7 @@ function emptyInspection(fixture, status) {
373
429
  function collectDetailedMatches(text, regex, filePath, key) {
374
430
  const details = [];
375
431
  for (const match of text.matchAll(regex)) {
376
- const line = lineForOffset(text, match.index ?? 0);
432
+ const line = lineForOffset(text, match.indices?.[1]?.[0] ?? match.index ?? 0);
377
433
  details.push({
378
434
  [key]: match[1],
379
435
  file: filePath,
@@ -413,6 +469,16 @@ function collectSdkImports(text, filePath) {
413
469
  ref: `${filePath}:${line}`,
414
470
  });
415
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
+ }
416
482
  return details.sort((left, right) => left.line - right.line || left.specifier.localeCompare(right.specifier));
417
483
  }
418
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 { createMockSdkPackage } from "./sdk-mock.js";
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
- writeRunnerStdout(`${JSON.stringify(result, null, 2)}\n`);
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.exitCode = 1;
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 { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
33
- register(pathToFileURL(loaderPath));
34
- return await captureLinkedEntrypoint(entrypoint, { ...options, pluginRoot });
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 withProcessOutput(
77
+ return finishCapture(
59
78
  {
60
79
  status: "no-register-export",
61
- entrypoint: options.entrypoint,
62
- mockSdk: true,
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: true,
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 stdoutChunks = [];
128
- const stderrChunks = [];
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
- stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
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
- stderrChunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
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: () => stdoutChunks.join(""),
147
- stderr: () => stderrChunks.join(""),
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
- (activeOutputCapture?.originalStdoutWrite ?? process.stdout.write.bind(process.stdout))(text);
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
- (activeOutputCapture?.originalStderrWrite ?? process.stderr.write.bind(process.stderr))(text);
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
  }
@@ -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 metadata = await fetchJson(`${registryUrl}/openclaw`, fetchImpl);
29
- version = metadata["dist-tags"]?.[requested];
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(downloadUrlFor(resolvedTarget));
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 = Buffer.from(await response.arrayBuffer());
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 fetchImpl(url, { headers: { accept: "application/json" } });
196
- if (!response.ok) throw new Error(`failed to resolve OpenClaw npm metadata: HTTP ${response.status}`);
197
- return response.json();
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) {