@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/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
- const { stdout } = await execFileAsync(
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
- const captured = parseCaptureResultFromStdout(error?.stdout);
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 parseCaptureResultFromStdout(stdout) {
260
- if (!stdout) {
261
- 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
+ });
262
292
  }
263
- try {
264
- const parsed = JSON.parse(stdout);
265
- if (
266
- parsed &&
267
- typeof parsed === "object" &&
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 (missingModule || rawMessage.includes("openclaw/plugin-sdk")) {
313
+ if (mockSdk && missingModule) {
294
314
  return enrichCaptureError(error, {
295
- message: `Mock SDK import failed: ${missingModule ?? "openclaw/plugin-sdk module could not be resolved"}`,
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:[^\]]+\]/, "")) ?? "Mock SDK capture failed",
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) ?? "Mock SDK capture failed",
311
- failureClass: "mock-sdk-capture-error",
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 { 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) {