@adhisang/minecraft-modding-mcp 7.0.0-rc.0 → 7.0.0-rc.2

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +131 -49
  2. package/README.md +2 -0
  3. package/dist/cache-registry.d.ts +10 -0
  4. package/dist/cache-registry.js +44 -3
  5. package/dist/entry-tools/batch-class-members-service.js +8 -0
  6. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +21 -0
  7. package/dist/entry-tools/inspect-minecraft/internal.d.ts +4 -0
  8. package/dist/entry-tools/manage-cache-service.d.ts +2 -2
  9. package/dist/entry-tools/validate-project/cases/mixin.js +26 -6
  10. package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
  11. package/dist/entry-tools/validate-project-service.d.ts +2 -2
  12. package/dist/entry-tools/verify-mixin-target-service.js +19 -1
  13. package/dist/error-mapping.d.ts +118 -0
  14. package/dist/error-mapping.js +177 -7
  15. package/dist/index.js +3 -1
  16. package/dist/json-rpc-framing.d.ts +53 -1
  17. package/dist/json-rpc-framing.js +190 -25
  18. package/dist/mapping/loaders/tiny-loom-selection.js +10 -3
  19. package/dist/mapping/parsers/tiny.js +4 -0
  20. package/dist/mapping-service.js +24 -3
  21. package/dist/maven-resolver.d.ts +61 -0
  22. package/dist/maven-resolver.js +95 -4
  23. package/dist/maven-token.d.ts +65 -0
  24. package/dist/maven-token.js +120 -0
  25. package/dist/mcp-helpers.js +14 -3
  26. package/dist/minecraft-explorer-service.d.ts +13 -1
  27. package/dist/minecraft-explorer-service.js +200 -7
  28. package/dist/repo-downloader.d.ts +260 -0
  29. package/dist/repo-downloader.js +1083 -14
  30. package/dist/source/artifact-resolver.d.ts +97 -3
  31. package/dist/source/artifact-resolver.js +228 -24
  32. package/dist/source/class-source.d.ts +15 -0
  33. package/dist/source/class-source.js +163 -18
  34. package/dist/source/indexer.js +14 -0
  35. package/dist/source/nested-jars.d.ts +16 -1
  36. package/dist/source/nested-jars.js +66 -3
  37. package/dist/source/workspace-target.js +39 -20
  38. package/dist/source-jar-reader.d.ts +30 -2
  39. package/dist/source-jar-reader.js +39 -8
  40. package/dist/source-resolver.d.ts +48 -0
  41. package/dist/source-resolver.js +735 -81
  42. package/dist/source-service.d.ts +10 -1
  43. package/dist/stdio-supervisor.d.ts +75 -1
  44. package/dist/stdio-supervisor.js +240 -14
  45. package/dist/storage/artifacts-repo.d.ts +2 -0
  46. package/dist/storage/artifacts-repo.js +22 -0
  47. package/dist/storage/db.js +32 -10
  48. package/dist/tool-guidance.js +17 -7
  49. package/dist/tool-schemas.d.ts +10 -2
  50. package/dist/tool-schemas.js +4 -0
  51. package/dist/v1-parity-schemas.js +16 -0
  52. package/dist/version-diff-service.js +25 -4
  53. package/dist/version-service.js +23 -8
  54. package/dist/warning-details.js +3 -1
  55. package/dist/workspace-mapping-service.d.ts +8 -1
  56. package/dist/workspace-mapping-service.js +28 -19
  57. package/docs/tool-reference.md +31 -7
  58. package/package.json +6 -4
@@ -32,6 +32,122 @@ export function extractDidYouMean(details) {
32
32
  }
33
33
  return cleaned.slice(0, MAX_DID_YOU_MEAN_ENTRIES);
34
34
  }
35
+ // A shell jar bundles a handful of inner jars, not hundreds; the cap only
36
+ // bounds a pathological inventory, it is not an expected truncation point.
37
+ const MAX_NESTED_JAR_ENTRIES = 64;
38
+ // Per-entry length ceiling, in UTF-8 bytes. Real entries are jar-relative paths
39
+ // like "META-INF/jars/fabric-screen-handler-api-v1-2.0.5.jar" (~52 bytes); the
40
+ // zip format itself allows a 65535-byte name, so an entry name is attacker-sized
41
+ // unless bounded here. 256 bytes is ~5x the longest realistic entry and still
42
+ // leaves any genuine name intact.
43
+ const MAX_NESTED_JAR_ENTRY_BYTES = 256;
44
+ // Total ceiling for the published list, in UTF-8 bytes. A full fabric-api-style
45
+ // inventory (64 entries at ~52 bytes) is ~3.3 KiB, so 8 KiB carries every real
46
+ // inventory whole while bounding the worst case this field can add to an error
47
+ // payload — which matters because nothing else in this project bounds an
48
+ // outbound response (MCP_MAX_FRAME_BYTES governs inbound decoding only).
49
+ const MAX_NESTED_JARS_TOTAL_BYTES = 8 * 1024;
50
+ /**
51
+ * Validates and extracts a `nestedJars` inventory from error details.
52
+ * `buildClassSourceNotFoundError` records it whenever the lookup ran against a
53
+ * shell jar, but `context` is primitive-only, so without this the inventory
54
+ * never left the process.
55
+ *
56
+ * Two distinct dispositions, deliberately not conflated:
57
+ * - MALFORMED (a non-string or empty-string element, anywhere in the array):
58
+ * the whole array is dropped, matching {@link extractDidYouMean}. The scan
59
+ * deliberately continues past the caps so a malformed element beyond them is
60
+ * still found — the upstream array is a real jar's own entry list, bounded by
61
+ * the archive, so the full scan is not an exposure.
62
+ * - OVERSIZED (an entry longer than {@link MAX_NESTED_JAR_ENTRY_BYTES}, an
63
+ * entry past the count cap, or one that would push the list past the total
64
+ * byte budget): that ENTRY alone is excluded and `nestedJarsTruncated` is
65
+ * set. An implausible name is not evidence that the rest of the inventory is
66
+ * untrustworthy, so it must not drop the array.
67
+ *
68
+ * An empty inventory is dropped as before: the producing site omits the key
69
+ * entirely in that case, so an empty array carries no information a caller could
70
+ * act on. If the caps leave nothing publishable, both keys are omitted rather
71
+ * than publishing a truncation flag with no list beside it.
72
+ */
73
+ export function extractNestedJarsField(details) {
74
+ const raw = details?.nestedJars;
75
+ if (!Array.isArray(raw) || raw.length === 0) {
76
+ return {};
77
+ }
78
+ const cleaned = [];
79
+ let truncated = false;
80
+ let totalBytes = 0;
81
+ let budgetExhausted = false;
82
+ for (const entry of raw) {
83
+ if (typeof entry !== "string" || !entry) {
84
+ return {};
85
+ }
86
+ if (budgetExhausted || cleaned.length >= MAX_NESTED_JAR_ENTRIES) {
87
+ truncated = true;
88
+ continue;
89
+ }
90
+ const entryBytes = Buffer.byteLength(entry, "utf8");
91
+ if (entryBytes > MAX_NESTED_JAR_ENTRY_BYTES) {
92
+ truncated = true;
93
+ continue;
94
+ }
95
+ if (totalBytes + entryBytes > MAX_NESTED_JARS_TOTAL_BYTES) {
96
+ // Keep the published list a prefix of the surviving entries: once the
97
+ // budget is spent, stop admitting rather than cherry-picking short names
98
+ // from the tail.
99
+ truncated = true;
100
+ budgetExhausted = true;
101
+ continue;
102
+ }
103
+ totalBytes += entryBytes;
104
+ cleaned.push(entry);
105
+ }
106
+ if (cleaned.length === 0) {
107
+ return {};
108
+ }
109
+ return { nestedJars: cleaned, ...(truncated ? { nestedJarsTruncated: true } : {}) };
110
+ }
111
+ /**
112
+ * Inventory-only view of {@link extractNestedJarsField}.
113
+ *
114
+ * NOT for an emission site. All three - the tool envelope, the batch entry, the
115
+ * error resource - publish the field pair, because dropping the flag makes a
116
+ * shortened inventory indistinguishable from a complete one. This remains for
117
+ * callers that want the list alone (tests pinning the validation and capping
118
+ * rules), and a new publisher should reach for the field-returning form.
119
+ */
120
+ export function extractNestedJars(details) {
121
+ return extractNestedJarsField(details).nestedJars;
122
+ }
123
+ const ISSUE_ORIGIN_VALUES = new Set([
124
+ "code_issue",
125
+ "tool_issue",
126
+ "environment"
127
+ ]);
128
+ /**
129
+ * Per-throw-site {@link IssueOrigin} override carried on an AppError's
130
+ * `details.issueOrigin`.
131
+ *
132
+ * The default classification is keyed purely on the error CODE, which is right
133
+ * for codes whose every throw site shares one origin. A few codes do not:
134
+ * `ERR_CONTEXT_UNRESOLVED` covers both a caller naming a version no artifact
135
+ * carries (genuinely `code_issue`) and the tool resolving an artifact with no
136
+ * binary jar behind the caller's back (`tool_issue`). Publishing the latter as
137
+ * caller-fixable sends an agent into a retry loop over input it cannot repair.
138
+ *
139
+ * This override is deliberately opt-in and per-error: `issueOriginForErrorCode`
140
+ * and its default sets stay untouched, so the exhaustive classification map in
141
+ * tests/runtime/error-mapping.test.ts keeps forcing a conscious decision for
142
+ * every new code. The key is not in `CONTEXT_ALLOWLIST`, so it cannot leak into
143
+ * the public `context` blob.
144
+ */
145
+ export function extractIssueOriginOverride(details) {
146
+ const raw = details?.issueOrigin;
147
+ return typeof raw === "string" && ISSUE_ORIGIN_VALUES.has(raw)
148
+ ? raw
149
+ : undefined;
150
+ }
35
151
  export function statusForErrorCode(code) {
36
152
  if (code === ERROR_CODES.BATCH_ABORTED) {
37
153
  return 412;
@@ -200,6 +316,51 @@ export function issueOriginForErrorCode(code) {
200
316
  }
201
317
  return "tool_issue";
202
318
  }
319
+ /**
320
+ * The single source of truth for a ProblemDetails classification pair, and the
321
+ * ONLY supported way to compute one for an error the server caught. Every site
322
+ * that turns a caught error into a published envelope spreads this result — the
323
+ * tool envelope (`mapErrorToProblem`), batch entries, the batch-aborted
324
+ * sentinel, and the `mc://` resource read — so one failure always publishes the
325
+ * same `{ retryClass, issueOrigin }` no matter which access path the caller
326
+ * used.
327
+ *
328
+ * KNOWN EXCEPTION — the synthetic supervisor replies in `src/stdio-supervisor.ts`
329
+ * (`buildSupervisorQueueLimitReply`, `buildValidateProjectTimeoutReply`) write
330
+ * the pair as literals and never import this builder. They are not emission
331
+ * sites for a caught error at all: the supervisor answers a request the worker
332
+ * never got to run, so there is no source `AppError` whose `details` could carry
333
+ * an override and nothing to pass as the required `details` argument. Their
334
+ * literals are `transient` / `tool_issue`, which is exactly what this function
335
+ * returns for `ERR_LIMIT_EXCEEDED` and `ERR_TOOL_TIMEOUT` today — but that is a
336
+ * duplicated value held in agreement by hand, not a second call into this
337
+ * classifier. Reclassifying either code here must be mirrored there.
338
+ *
339
+ * It exists because that pair used to be assembled by hand at each site. The
340
+ * `issueOrigin` half has a per-throw-site override (see
341
+ * {@link extractIssueOriginOverride}), and honouring it was copy-pasted into
342
+ * some sites and forgotten in others — which is exactly how one identical
343
+ * AppError came to publish `tool_issue` through a tool call and `code_issue`
344
+ * through a resource read. Composing the pair here removes the opportunity:
345
+ * emission modules import this function and no longer reach the component
346
+ * classifiers at all.
347
+ *
348
+ * `details` is REQUIRED, deliberately without a default. A site that genuinely
349
+ * has no AppError behind it (a ZodError, a sanitized non-AppError, the
350
+ * batch-aborted sentinel) must pass `undefined` explicitly, so skipping the
351
+ * override can only ever be a written-down decision rather than an omission.
352
+ *
353
+ * Only `issueOrigin` is overridable per throw site. `retryClass` stays purely
354
+ * code-derived: it is a documented wire contract, and changing the value an
355
+ * existing code publishes is a Breaking change, so a `retryClass` override seam
356
+ * was deliberately deferred rather than added alongside this one.
357
+ */
358
+ export function problemClassification(code, details) {
359
+ return {
360
+ retryClass: retryClassForErrorCode(code),
361
+ issueOrigin: extractIssueOriginOverride(details) ?? issueOriginForErrorCode(code)
362
+ };
363
+ }
203
364
  // Non-sensitive AppError.details fields that are safe to echo to callers as
204
365
  // machine-readable repair context. Filesystem paths and free-form text are
205
366
  // intentionally excluded.
@@ -220,7 +381,14 @@ const CONTEXT_ALLOWLIST = new Set([
220
381
  "maxMembers",
221
382
  "candidateCount",
222
383
  "candidatesSeen",
223
- "ambiguous"
384
+ "ambiguous",
385
+ // Why a repository cascade gave up, as the failing leg's own error code (e.g.
386
+ // "ERR_LIMIT_EXCEEDED"). The terminal ERR_REPO_FETCH_FAILED says only that
387
+ // repositories were unstable, which is wrong for a configuration-driven
388
+ // refusal; the underlying code is the machine-readable half of that
389
+ // correction, beside the human-readable `nextAction` hint. A bare code string
390
+ // - no url, no path, no message - which is why it can travel here at all.
391
+ "repoFailureCode"
224
392
  ]);
225
393
  /**
226
394
  * Pick the allowlisted, primitive-valued fields out of an AppError's `details`
@@ -282,6 +450,7 @@ export function errorToBatchEntryProblem(caughtError, instance, options) {
282
450
  const fieldErrors = extractFieldErrors(caughtError.details);
283
451
  const context = extractAllowlistedContext(caughtError.details);
284
452
  const didYouMean = extractDidYouMean(caughtError.details);
453
+ const nestedJarsField = extractNestedJarsField(caughtError.details);
285
454
  return {
286
455
  type: `https://minecraft-modding-mcp.dev/problems/${caughtError.code.toLowerCase()}`,
287
456
  title: "Tool execution error",
@@ -289,12 +458,12 @@ export function errorToBatchEntryProblem(caughtError, instance, options) {
289
458
  status: statusForErrorCode(caughtError.code),
290
459
  code: caughtError.code,
291
460
  instance,
292
- retryClass: retryClassForErrorCode(caughtError.code),
293
- issueOrigin: issueOriginForErrorCode(caughtError.code),
461
+ ...problemClassification(caughtError.code, caughtError.details),
294
462
  ...(fieldErrors ? { fieldErrors } : {}),
295
463
  ...(baseHints ? { hints: baseHints } : {}),
296
464
  ...(options?.suggestedCall ? { suggestedCall: options.suggestedCall } : {}),
297
465
  ...(didYouMean ? { didYouMean } : {}),
466
+ ...nestedJarsField,
298
467
  ...(context ? { context } : {})
299
468
  };
300
469
  }
@@ -314,8 +483,9 @@ export function errorToBatchEntryProblem(caughtError, instance, options) {
314
483
  status: 500,
315
484
  code: ERROR_CODES.INTERNAL,
316
485
  instance,
317
- retryClass: retryClassForErrorCode(ERROR_CODES.INTERNAL),
318
- issueOrigin: issueOriginForErrorCode(ERROR_CODES.INTERNAL),
486
+ // No AppError behind this envelope: the throw was sanitized away, so there
487
+ // is no per-site override to honour.
488
+ ...problemClassification(ERROR_CODES.INTERNAL, undefined),
319
489
  ...(options?.suggestedCall ? { suggestedCall: options.suggestedCall } : {})
320
490
  };
321
491
  }
@@ -327,8 +497,8 @@ export function buildBatchAbortedProblem(instance) {
327
497
  status: 412,
328
498
  code: ERROR_CODES.BATCH_ABORTED,
329
499
  instance,
330
- retryClass: retryClassForErrorCode(ERROR_CODES.BATCH_ABORTED),
331
- issueOrigin: issueOriginForErrorCode(ERROR_CODES.BATCH_ABORTED)
500
+ // Synthesized sentinel, not a caught AppError: no per-site override exists.
501
+ ...problemClassification(ERROR_CODES.BATCH_ABORTED, undefined)
332
502
  };
333
503
  }
334
504
  //# sourceMappingURL=error-mapping.js.map
package/dist/index.js CHANGED
@@ -175,7 +175,9 @@ const validateProjectService = new ValidateProjectService({
175
175
  const manageCacheService = new ManageCacheService({
176
176
  registry: createCacheRegistry({
177
177
  cacheDir: config.cacheDir,
178
- sqlitePath: config.sqlitePath
178
+ sqlitePath: config.sqlitePath,
179
+ sqliteCacheKb: config.sqliteCacheKb,
180
+ sqliteMmapSize: config.sqliteMmapSize
179
181
  })
180
182
  });
181
183
  const verifyMixinTargetService = new VerifyMixinTargetService({
@@ -5,6 +5,14 @@ export type ParsedJsonRpcFrame = {
5
5
  message: JSONRPCMessage;
6
6
  mode: ConcreteFramingMode;
7
7
  };
8
+ /**
9
+ * The opaque handle a {@link JsonRpcFrameReader}'s idle-budget timer is
10
+ * identified by. Only the reader's own scheduler/clearer pair interprets it,
11
+ * so a test can substitute a plain object for a real timer.
12
+ */
13
+ export type FrameIdleTimerHandle = unknown;
14
+ export type FrameIdleTimerScheduler = (callback: () => void, delayMs: number) => FrameIdleTimerHandle;
15
+ export type FrameIdleTimerClearer = (handle: FrameIdleTimerHandle) => void;
8
16
  /**
9
17
  * A framing violation the reader cannot provably recover from.
10
18
  *
@@ -32,10 +40,15 @@ export type ParsedJsonRpcFrame = {
32
40
  * - an oversized Content-Length whose declared body has NOT fully arrived
33
41
  * (waiting on it is what let a single unanswerable header wedge the
34
42
  * transport for the process lifetime),
43
+ * - an UNDER-limit Content-Length whose declared body stops arriving for the
44
+ * incomplete-frame idle budget — the same wedge, below the size check,
35
45
  * - a Content-Length body that is not valid JSON — under-declaration,
36
46
  * over-declaration and an honestly-framed bad body are indistinguishable,
37
47
  * and the first two have already desynchronized the stream,
38
- * - duplicate Content-Length headers — the body length is ambiguous,
48
+ * - a header block that declared a body length and then contradicted it
49
+ * (duplicate Content-Length headers, a non-numeric value alongside a
50
+ * numeric one, junk after a good declaration) — bytes the peer counted as
51
+ * body would otherwise be re-read as frames,
39
52
  * - a Content-Length header block that never terminates within the header
40
53
  * limit — there is no delimiter left to resynchronize on.
41
54
  */
@@ -54,14 +67,34 @@ export declare function loadMaxFrameBytes(value?: string | undefined): number;
54
67
  export declare function encodeJsonRpcMessage(message: JSONRPCMessage, mode: ConcreteFramingMode): Buffer;
55
68
  export declare class JsonRpcFrameReader {
56
69
  private readonly maxFrameBytes;
70
+ private readonly incompleteFrameIdleMs;
71
+ private readonly scheduleTimer;
72
+ private readonly clearTimer;
57
73
  private mode;
58
74
  private buffer;
59
75
  private pendingChunks;
60
76
  private pendingBytes;
61
77
  private awaitedFrameEnd;
78
+ private awaitedBodyStart;
79
+ private idleTimer;
62
80
  private fatal;
81
+ /**
82
+ * @param options.maxFrameBytes Largest accepted frame; defaults to
83
+ * {@link loadMaxFrameBytes}.
84
+ * @param options.incompleteFrameIdleMs How long a declared Content-Length
85
+ * body may stop arriving before the session is terminated. This is IDLE
86
+ * time, not total time: every arriving byte clears and re-arms it, so a
87
+ * legitimately slow or very large body is never cut off. Defaults to
88
+ * 30 000 ms; a non-positive or non-finite value disables the budget.
89
+ * @param options.timerScheduler Schedules the idle budget; defaults to an
90
+ * `unref()`'d `setTimeout`. Injectable so tests can drive it directly.
91
+ * @param options.timerClearer Cancels a handle from `timerScheduler`.
92
+ */
63
93
  constructor(options?: {
64
94
  maxFrameBytes?: number;
95
+ incompleteFrameIdleMs?: number;
96
+ timerScheduler?: FrameIdleTimerScheduler;
97
+ timerClearer?: FrameIdleTimerClearer;
65
98
  });
66
99
  get currentMode(): FramingMode;
67
100
  /**
@@ -76,6 +109,25 @@ export declare class JsonRpcFrameReader {
76
109
  onFrame: (frame: ParsedJsonRpcFrame) => void;
77
110
  onError: (error: Error) => void;
78
111
  }): void;
112
+ private drainChunk;
113
+ private clearIdleTimer;
114
+ /**
115
+ * Puts an incomplete declared body on an idle clock.
116
+ *
117
+ * A Content-Length UNDER the frame limit whose body never arrives was the
118
+ * one wedge the size check could not see: `readContentLengthMessage` armed
119
+ * `awaitedFrameEnd`, `canCompleteFrame` then refused to look at anything
120
+ * until that many bytes existed, and every later frame piled up behind a
121
+ * body that was never coming. The budget is idle time — the caller clears it
122
+ * on every arriving chunk and this re-arms it — so only a stream that has
123
+ * gone silent mid-body is terminated.
124
+ *
125
+ * `onError` belongs to a `processChunk` call, so the timer fires through the
126
+ * handlers of the MOST RECENT call rather than the one that armed it. Every
127
+ * caller in this repository passes a stable handler pair on every chunk, and
128
+ * a caller that does not still gets a live pair rather than a stale one.
129
+ */
130
+ private armIdleTimer;
79
131
  private canCompleteFrame;
80
132
  private rejectOversizedIncompleteInput;
81
133
  /**
@@ -1,13 +1,34 @@
1
+ import { clearTimeout as clearNodeTimeout, setTimeout as setNodeTimeout } from "node:timers";
1
2
  import { parseJSONRPCMessage } from "@modelcontextprotocol/server";
2
3
  const DEFAULT_MAX_FRAME_BYTES = 64 * 1024 * 1024;
3
4
  const MIN_MAX_FRAME_BYTES = 1024 * 1024;
4
5
  const MAX_CONTENT_LENGTH_HEADER_BYTES = 8 * 1024;
6
+ const DEFAULT_INCOMPLETE_FRAME_IDLE_MS = 30_000;
7
+ const scheduleIdleTimer = (callback, delayMs) => {
8
+ const timer = setNodeTimeout(callback, delayMs);
9
+ // The budget must never be the reason a process stays alive: a reader parked
10
+ // on an incomplete body would otherwise hold the event loop open for the
11
+ // whole budget after every other handle had closed.
12
+ timer.unref();
13
+ return timer;
14
+ };
15
+ const clearIdleTimerHandle = (handle) => {
16
+ clearNodeTimeout(handle);
17
+ };
18
+ /**
19
+ * The FIRST header terminator in the buffer, whichever style it is.
20
+ *
21
+ * Preferring a CRLFCRLF found anywhere over an earlier LFLF mis-framed every
22
+ * LF-framed peer whose JSON body happened to contain a raw `\r\n\r\n` — legal
23
+ * inter-token whitespace — because the header block was then cut at a boundary
24
+ * inside the body, losing that frame and the next.
25
+ */
5
26
  function findHeaderBoundary(buffer) {
6
27
  const crlfBoundary = buffer.indexOf("\r\n\r\n");
7
- if (crlfBoundary !== -1) {
28
+ const lfBoundary = buffer.indexOf("\n\n");
29
+ if (crlfBoundary !== -1 && (lfBoundary === -1 || crlfBoundary < lfBoundary)) {
8
30
  return { index: crlfBoundary, delimiterBytes: 4 };
9
31
  }
10
- const lfBoundary = buffer.indexOf("\n\n");
11
32
  if (lfBoundary !== -1) {
12
33
  return { index: lfBoundary, delimiterBytes: 2 };
13
34
  }
@@ -46,10 +67,15 @@ function asError(value) {
46
67
  * - an oversized Content-Length whose declared body has NOT fully arrived
47
68
  * (waiting on it is what let a single unanswerable header wedge the
48
69
  * transport for the process lifetime),
70
+ * - an UNDER-limit Content-Length whose declared body stops arriving for the
71
+ * incomplete-frame idle budget — the same wedge, below the size check,
49
72
  * - a Content-Length body that is not valid JSON — under-declaration,
50
73
  * over-declaration and an honestly-framed bad body are indistinguishable,
51
74
  * and the first two have already desynchronized the stream,
52
- * - duplicate Content-Length headers — the body length is ambiguous,
75
+ * - a header block that declared a body length and then contradicted it
76
+ * (duplicate Content-Length headers, a non-numeric value alongside a
77
+ * numeric one, junk after a good declaration) — bytes the peer counted as
78
+ * body would otherwise be re-read as frames,
53
79
  * - a Content-Length header block that never terminates within the header
54
80
  * limit — there is no delimiter left to resynchronize on.
55
81
  */
@@ -87,14 +113,34 @@ export function encodeJsonRpcMessage(message, mode) {
87
113
  }
88
114
  export class JsonRpcFrameReader {
89
115
  maxFrameBytes;
116
+ incompleteFrameIdleMs;
117
+ scheduleTimer;
118
+ clearTimer;
90
119
  mode = "unknown";
91
120
  buffer = Buffer.alloc(0);
92
121
  pendingChunks = [];
93
122
  pendingBytes = 0;
94
123
  awaitedFrameEnd = -1;
124
+ awaitedBodyStart = -1;
125
+ idleTimer;
95
126
  fatal = false;
127
+ /**
128
+ * @param options.maxFrameBytes Largest accepted frame; defaults to
129
+ * {@link loadMaxFrameBytes}.
130
+ * @param options.incompleteFrameIdleMs How long a declared Content-Length
131
+ * body may stop arriving before the session is terminated. This is IDLE
132
+ * time, not total time: every arriving byte clears and re-arms it, so a
133
+ * legitimately slow or very large body is never cut off. Defaults to
134
+ * 30 000 ms; a non-positive or non-finite value disables the budget.
135
+ * @param options.timerScheduler Schedules the idle budget; defaults to an
136
+ * `unref()`'d `setTimeout`. Injectable so tests can drive it directly.
137
+ * @param options.timerClearer Cancels a handle from `timerScheduler`.
138
+ */
96
139
  constructor(options = {}) {
97
140
  this.maxFrameBytes = options.maxFrameBytes ?? loadMaxFrameBytes();
141
+ this.incompleteFrameIdleMs = options.incompleteFrameIdleMs ?? DEFAULT_INCOMPLETE_FRAME_IDLE_MS;
142
+ this.scheduleTimer = options.timerScheduler ?? scheduleIdleTimer;
143
+ this.clearTimer = options.timerClearer ?? clearIdleTimerHandle;
98
144
  }
99
145
  get currentMode() {
100
146
  return this.mode;
@@ -108,22 +154,37 @@ export class JsonRpcFrameReader {
108
154
  return this.fatal;
109
155
  }
110
156
  reset() {
157
+ this.clearIdleTimer();
111
158
  this.mode = "unknown";
112
159
  this.awaitedFrameEnd = -1;
160
+ this.awaitedBodyStart = -1;
113
161
  this.fatal = false;
114
162
  }
115
163
  clear() {
164
+ this.clearIdleTimer();
116
165
  this.mode = "unknown";
117
166
  this.buffer = Buffer.alloc(0);
118
167
  this.pendingChunks = [];
119
168
  this.pendingBytes = 0;
120
169
  this.awaitedFrameEnd = -1;
170
+ this.awaitedBodyStart = -1;
121
171
  this.fatal = false;
122
172
  }
123
173
  processChunk(chunk, handlers) {
124
174
  if (chunk.length === 0 || this.fatal) {
125
175
  return;
126
176
  }
177
+ // Bytes arrived, so any pending idle budget is stale; it is re-armed below
178
+ // only if this chunk leaves a declared body still incomplete.
179
+ this.clearIdleTimer();
180
+ try {
181
+ this.drainChunk(chunk, handlers);
182
+ }
183
+ finally {
184
+ this.armIdleTimer(handlers);
185
+ }
186
+ }
187
+ drainChunk(chunk, handlers) {
127
188
  this.pendingChunks.push(chunk);
128
189
  this.pendingBytes += chunk.length;
129
190
  if (!this.canCompleteFrame(chunk)) {
@@ -153,15 +214,34 @@ export class JsonRpcFrameReader {
153
214
  }
154
215
  return;
155
216
  }
156
- handlers.onFrame({
157
- message,
158
- mode: this.mode
159
- });
217
+ try {
218
+ handlers.onFrame({
219
+ message,
220
+ mode: this.mode
221
+ });
222
+ }
223
+ catch (handlerError) {
224
+ // The frame HANDLER threw, not the framer. This frame's bytes are
225
+ // already consumed and the framing state describes the stream
226
+ // correctly, so it must survive untouched: sharing the framing
227
+ // try/catch turned a handler bug into a silent framing change (the
228
+ // compat transport picks its response framing from `currentMode`,
229
+ // so a reset here downgraded later replies to line framing) and left
230
+ // the offending request unanswered with no distinguishing signal.
231
+ // The failure is still surfaced through `onError` — the path every
232
+ // transport already handles — but as a plain Error, so a handler can
233
+ // never forge the framing-fatal signal that tears a session down.
234
+ const handlerFault = asError(handlerError);
235
+ handlers.onError(new Error(`JSON-RPC frame handler failed: ${handlerFault.message}`, {
236
+ cause: handlerFault
237
+ }));
238
+ }
160
239
  }
161
240
  catch (caughtError) {
162
241
  const error = asError(caughtError);
163
242
  this.mode = "unknown";
164
243
  this.awaitedFrameEnd = -1;
244
+ this.awaitedBodyStart = -1;
165
245
  if (error instanceof JsonRpcFramingFatalError) {
166
246
  // Terminal: drop everything buffered and refuse all further input so
167
247
  // no byte after the violation can be mistaken for a frame. The
@@ -177,6 +257,63 @@ export class JsonRpcFrameReader {
177
257
  }
178
258
  }
179
259
  }
260
+ clearIdleTimer() {
261
+ if (this.idleTimer === undefined) {
262
+ return;
263
+ }
264
+ const handle = this.idleTimer;
265
+ this.idleTimer = undefined;
266
+ this.clearTimer(handle);
267
+ }
268
+ /**
269
+ * Puts an incomplete declared body on an idle clock.
270
+ *
271
+ * A Content-Length UNDER the frame limit whose body never arrives was the
272
+ * one wedge the size check could not see: `readContentLengthMessage` armed
273
+ * `awaitedFrameEnd`, `canCompleteFrame` then refused to look at anything
274
+ * until that many bytes existed, and every later frame piled up behind a
275
+ * body that was never coming. The budget is idle time — the caller clears it
276
+ * on every arriving chunk and this re-arms it — so only a stream that has
277
+ * gone silent mid-body is terminated.
278
+ *
279
+ * `onError` belongs to a `processChunk` call, so the timer fires through the
280
+ * handlers of the MOST RECENT call rather than the one that armed it. Every
281
+ * caller in this repository passes a stable handler pair on every chunk, and
282
+ * a caller that does not still gets a live pair rather than a stale one.
283
+ */
284
+ armIdleTimer(handlers) {
285
+ if (this.fatal || this.awaitedFrameEnd < 0 || this.awaitedBodyStart < 0) {
286
+ return;
287
+ }
288
+ if (!Number.isFinite(this.incompleteFrameIdleMs) || this.incompleteFrameIdleMs <= 0) {
289
+ return;
290
+ }
291
+ let handle;
292
+ const expire = () => {
293
+ if (this.idleTimer === handle) {
294
+ this.idleTimer = undefined;
295
+ }
296
+ if (this.fatal || this.awaitedFrameEnd < 0 || this.awaitedBodyStart < 0) {
297
+ // Cleared, completed or already terminated between scheduling and now.
298
+ return;
299
+ }
300
+ const declaredBytes = this.awaitedFrameEnd - this.awaitedBodyStart;
301
+ const arrivedBytes = Math.max(0, this.buffer.length + this.pendingBytes - this.awaitedBodyStart);
302
+ this.fatal = true;
303
+ this.mode = "unknown";
304
+ this.buffer = Buffer.alloc(0);
305
+ this.pendingChunks = [];
306
+ this.pendingBytes = 0;
307
+ this.awaitedFrameEnd = -1;
308
+ this.awaitedBodyStart = -1;
309
+ handlers.onError(new JsonRpcFramingFatalError(`Content-Length declared ${declaredBytes} body bytes but only ${arrivedBytes} arrived ` +
310
+ `within the ${this.incompleteFrameIdleMs} ms incomplete-frame idle budget; the reader ` +
311
+ "cannot resynchronize without trusting bytes that may never be sent, so the stdio " +
312
+ "session is terminated."));
313
+ };
314
+ handle = this.scheduleTimer(expire, this.incompleteFrameIdleMs);
315
+ this.idleTimer = handle;
316
+ }
180
317
  canCompleteFrame(chunk) {
181
318
  const bufferedBytes = this.buffer.length + this.pendingBytes;
182
319
  if (this.mode === "content-length" && this.awaitedFrameEnd >= 0) {
@@ -237,6 +374,7 @@ export class JsonRpcFrameReader {
237
374
  if (BigInt(this.buffer.length) >= frameEnd) {
238
375
  this.buffer = this.buffer.subarray(Number(frameEnd));
239
376
  this.awaitedFrameEnd = -1;
377
+ this.awaitedBodyStart = -1;
240
378
  this.mode = "unknown";
241
379
  throw new Error(reason);
242
380
  }
@@ -311,6 +449,7 @@ export class JsonRpcFrameReader {
311
449
  }
312
450
  readContentLengthMessage() {
313
451
  this.awaitedFrameEnd = -1;
452
+ this.awaitedBodyStart = -1;
314
453
  // Skip blank separator lines between frames so the mid-stream mode check
315
454
  // below sees the first byte of the next frame.
316
455
  while (this.buffer.length > 0) {
@@ -356,36 +495,61 @@ export class JsonRpcFrameReader {
356
495
  .split(/\r?\n/)
357
496
  .map((line) => line.trim())
358
497
  .filter((line) => line.length > 0);
498
+ // The WHOLE block is inspected before anything is thrown. Throwing on the
499
+ // first bad line consumed only the header block, which left the body bytes
500
+ // an EARLIER Content-Length had already declared sitting in the buffer to
501
+ // be re-read as line frames — so `Content-Length: 5x` followed by
502
+ // `Content-Length: 44`, or a good declaration followed by junk, let the
503
+ // peer decide where the reader thought the frame ended.
359
504
  let contentLength;
505
+ let declaredValue;
506
+ let contentLengthLines = 0;
507
+ let headerFault;
360
508
  for (const headerLine of headerLines) {
361
509
  const separatorIndex = headerLine.indexOf(":");
362
510
  if (separatorIndex === -1) {
363
- this.buffer = this.buffer.subarray(headerBoundary.index + headerBoundary.delimiterBytes);
364
- throw new Error(`Malformed header line: ${headerLine}`);
511
+ headerFault ??= `Malformed header line: ${headerLine}`;
512
+ continue;
365
513
  }
366
514
  const headerName = headerLine.slice(0, separatorIndex).trim().toLowerCase();
367
515
  const headerValue = headerLine.slice(separatorIndex + 1).trim();
368
- if (headerName === "content-length") {
369
- if (contentLength !== undefined) {
370
- // Two declarations, no way to tell which delimits the body: the
371
- // classic frame-smuggling shape. Last-wins would hand an attacker
372
- // the choice of where the reader thinks this frame ends.
373
- throw new JsonRpcFramingFatalError(`Duplicate Content-Length header (${contentLength.toString()} then ${headerValue}): the ` +
374
- "declared body length is ambiguous, so the reader cannot determine where this frame " +
375
- "ends; the stdio session is terminated.");
376
- }
377
- if (!/^[0-9]+$/.test(headerValue)) {
378
- this.buffer = this.buffer.subarray(headerBoundary.index + headerBoundary.delimiterBytes);
379
- throw new Error(`Invalid Content-Length header value: ${headerValue}`);
380
- }
381
- contentLength = BigInt(headerValue);
516
+ if (headerName !== "content-length") {
517
+ continue;
518
+ }
519
+ contentLengthLines += 1;
520
+ if (contentLengthLines > 1) {
521
+ // Two declarations, no way to tell which delimits the body: the
522
+ // classic frame-smuggling shape. Last-wins would hand an attacker
523
+ // the choice of where the reader thinks this frame ends.
524
+ headerFault ??=
525
+ `Duplicate Content-Length header (${declaredValue ?? ""} then ${headerValue})`;
526
+ continue;
527
+ }
528
+ declaredValue = headerValue;
529
+ if (!/^[0-9]+$/.test(headerValue)) {
530
+ headerFault ??= `Invalid Content-Length header value: ${headerValue}`;
531
+ continue;
382
532
  }
533
+ contentLength = BigInt(headerValue);
534
+ }
535
+ const messageStart = headerBoundary.index + headerBoundary.delimiterBytes;
536
+ if (headerFault !== undefined) {
537
+ this.buffer = this.buffer.subarray(messageStart);
538
+ if (contentLength !== undefined || contentLengthLines > 1) {
539
+ // The block named a body length somewhere and then contradicted it, so
540
+ // bytes the peer counted as body may already be buffered. Consuming
541
+ // only the header block would re-dispatch them as frames.
542
+ throw new JsonRpcFramingFatalError(`${headerFault}: the declared body length is ambiguous, so the reader cannot determine ` +
543
+ "where this frame ends; the stdio session is terminated.");
544
+ }
545
+ // No usable length was ever declared, so the delimited header block is
546
+ // all there is to consume and the recovery is provable.
547
+ throw new Error(headerFault);
383
548
  }
384
549
  if (contentLength === undefined) {
385
- this.buffer = this.buffer.subarray(headerBoundary.index + headerBoundary.delimiterBytes);
550
+ this.buffer = this.buffer.subarray(messageStart);
386
551
  throw new Error("Missing Content-Length header.");
387
552
  }
388
- const messageStart = headerBoundary.index + headerBoundary.delimiterBytes;
389
553
  if (contentLength > BigInt(this.maxFrameBytes)) {
390
554
  this.rejectDeclaredBody(messageStart, contentLength, `Content-Length ${contentLength.toString()} exceeds the configured frame limit of ` +
391
555
  `${this.maxFrameBytes} bytes.`);
@@ -397,6 +561,7 @@ export class JsonRpcFrameReader {
397
561
  const frameEnd = messageStart + Number(contentLength);
398
562
  if (this.buffer.length < frameEnd) {
399
563
  this.awaitedFrameEnd = frameEnd;
564
+ this.awaitedBodyStart = messageStart;
400
565
  return undefined;
401
566
  }
402
567
  const body = this.buffer.subarray(messageStart, frameEnd).toString("utf8");