@pentoshi/clai 3.8.44 → 3.8.46

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 (76) hide show
  1. package/README.md +249 -251
  2. package/dist/attachments/clipboard-image.d.ts +10 -0
  3. package/dist/attachments/clipboard-image.js +188 -0
  4. package/dist/attachments/clipboard-image.js.map +1 -0
  5. package/dist/attachments/image-content.d.ts +3 -0
  6. package/dist/attachments/image-content.js +51 -0
  7. package/dist/attachments/image-content.js.map +1 -0
  8. package/dist/attachments/service.js +6 -2
  9. package/dist/attachments/service.js.map +1 -1
  10. package/dist/commands/providers.js +4 -2
  11. package/dist/commands/providers.js.map +1 -1
  12. package/dist/commands/search-providers.d.ts +1 -0
  13. package/dist/commands/search-providers.js +4 -2
  14. package/dist/commands/search-providers.js.map +1 -1
  15. package/dist/llm/capabilities.js +17 -4
  16. package/dist/llm/capabilities.js.map +1 -1
  17. package/dist/prompts/embedded.js +2 -2
  18. package/dist/prompts/embedded.js.map +1 -1
  19. package/dist/prompts/index.js +1 -1
  20. package/dist/prompts/system.agent.md +5 -3
  21. package/dist/prompts/system.ask.md +1 -1
  22. package/dist/repl/prompt-line.js +39 -6
  23. package/dist/repl/prompt-line.js.map +1 -1
  24. package/dist/repl.js +1 -1
  25. package/dist/repl.js.map +1 -1
  26. package/dist/store/config.d.ts +5 -1
  27. package/dist/store/config.js +8 -0
  28. package/dist/store/config.js.map +1 -1
  29. package/dist/store/history.js +43 -5
  30. package/dist/store/history.js.map +1 -1
  31. package/dist/store/keys.js +1 -0
  32. package/dist/store/keys.js.map +1 -1
  33. package/dist/tools/definitions.js +13 -7
  34. package/dist/tools/definitions.js.map +1 -1
  35. package/dist/tools/http.d.ts +6 -1
  36. package/dist/tools/http.js +247 -68
  37. package/dist/tools/http.js.map +1 -1
  38. package/dist/tools/registry.js +2 -1
  39. package/dist/tools/registry.js.map +1 -1
  40. package/dist/tools/web/decode.d.ts +17 -0
  41. package/dist/tools/web/decode.js +65 -0
  42. package/dist/tools/web/decode.js.map +1 -0
  43. package/dist/tools/web/fetch-core.js +14 -20
  44. package/dist/tools/web/fetch-core.js.map +1 -1
  45. package/dist/tools/web/providers/exa.d.ts +51 -0
  46. package/dist/tools/web/providers/exa.js +303 -0
  47. package/dist/tools/web/providers/exa.js.map +1 -0
  48. package/dist/tools/web/readable.d.ts +5 -5
  49. package/dist/tools/web/readable.js +34 -12
  50. package/dist/tools/web/readable.js.map +1 -1
  51. package/dist/tools/web/search.d.ts +3 -1
  52. package/dist/tools/web/search.js +6 -4
  53. package/dist/tools/web/search.js.map +1 -1
  54. package/dist/tools/web/types.d.ts +25 -2
  55. package/dist/tools/web/types.js +32 -0
  56. package/dist/tools/web/types.js.map +1 -1
  57. package/dist/tui-v2/app/commands/key-commands.js +1 -1
  58. package/dist/tui-v2/app/commands/key-commands.js.map +1 -1
  59. package/dist/tui-v2/app/commands/picker-commands.js +59 -8
  60. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  61. package/dist/tui-v2/components/transcript/intro-card.js +11 -5
  62. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  63. package/dist/tui-v2/composer/composer-editor.js +11 -9
  64. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  65. package/dist/tui-v2/composer/composer-image-paste.d.ts +19 -0
  66. package/dist/tui-v2/composer/composer-image-paste.js +56 -0
  67. package/dist/tui-v2/composer/composer-image-paste.js.map +1 -0
  68. package/dist/ui/keys.d.ts +1 -0
  69. package/dist/ui/keys.js +4 -0
  70. package/dist/ui/keys.js.map +1 -1
  71. package/dist/ui/mentions.d.ts +2 -16
  72. package/dist/ui/mentions.js +132 -102
  73. package/dist/ui/mentions.js.map +1 -1
  74. package/dist/version.generated.d.ts +2 -2
  75. package/dist/version.generated.js +2 -2
  76. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
1
  import net from "node:net";
2
+ import { createHash } from "node:crypto";
2
3
  import { lookup } from "node:dns/promises";
3
4
  import { Agent } from "undici";
4
5
  import { isBlockedAddress } from "./web/ssrf-guard.js";
6
+ import { decodeTextBody } from "./web/decode.js";
5
7
  import { toReadableText } from "./web/readable.js";
6
8
  import { selectOutput, } from "./output-selection.js";
7
9
  /** Shared undici agent that skips cert verification (authorized pentest only). */
@@ -16,6 +18,53 @@ function getInsecureTlsAgent() {
16
18
  }
17
19
  return insecureTlsAgent;
18
20
  }
21
+ /**
22
+ * Pin Fetch to addresses that already passed policy. If policy pre-resolution
23
+ * was unavailable (for example, an injected test transport), the dispatcher's
24
+ * own lookup performs the SSRF classification immediately before connect so
25
+ * there is still no unchecked second DNS resolution.
26
+ */
27
+ function createPinnedAgent(addresses, insecureTls, allowBlockedAddresses) {
28
+ const preResolved = toLookupAddresses(addresses);
29
+ return new Agent({
30
+ connect: {
31
+ rejectUnauthorized: !insecureTls,
32
+ lookup: (hostname, options, callback) => {
33
+ const send = (resolved) => {
34
+ if (resolved.length === 0) {
35
+ callback(new Error(`DNS resolution returned no addresses for ${hostname}`), "");
36
+ return;
37
+ }
38
+ if (!allowBlockedAddresses &&
39
+ (isBlockedAddress(hostname) ||
40
+ resolved.some((entry) => isBlockedAddress(entry.address)))) {
41
+ callback(new Error(`Refusing private/loopback/metadata DNS result for ${hostname}`), "");
42
+ return;
43
+ }
44
+ if (options && typeof options === "object" && options.all === true) {
45
+ callback(null, resolved);
46
+ return;
47
+ }
48
+ const selected = resolved[0];
49
+ callback(null, selected.address, selected.family);
50
+ };
51
+ if (preResolved.length > 0) {
52
+ send(preResolved);
53
+ return;
54
+ }
55
+ void lookup(hostname, { all: true }).then((results) => send(toLookupAddresses(results.map((entry) => entry.address))), (error) => callback(error instanceof Error ? error : new Error(String(error)), ""));
56
+ },
57
+ },
58
+ });
59
+ }
60
+ function toLookupAddresses(addresses) {
61
+ return addresses
62
+ .map((address) => ({ address, family: net.isIP(address) }))
63
+ .filter((entry) => entry.family === 4 || entry.family === 6);
64
+ }
65
+ async function closeAgents(agents) {
66
+ await Promise.allSettled(agents.map(async (agent) => agent.close()));
67
+ }
19
68
  /** Flatten error + nested cause(s) for TLS code matching. */
20
69
  function errorText(error, depth = 0) {
21
70
  if (depth > 4 || error == null)
@@ -77,8 +126,10 @@ function formatTlsNetworkError(error, url) {
77
126
  ` 3. shell: curl -k -i ${url}\n` +
78
127
  `Evidence taken with insecureTls is still valid for recon; record that TLS verification was disabled.`);
79
128
  }
80
- /** Capture budget for body bytes (artifact + evidence build). Model context is capped later. */
129
+ /** Capture budget for decoded response-body bytes (artifact + evidence build). */
81
130
  const DEFAULT_MAX_BYTES = 128 * 1024;
131
+ /** Hard memory ceiling for one response capture; raise maxBytes up to this cap. */
132
+ const MAX_CAPTURE_BYTES = 16 * 1024 * 1024;
82
133
  /**
83
134
  * Default retries for remote evidence: 0 so intentional 5xx/probe responses are
84
135
  * not silently rewritten. Callers may pass retries; owned loopback still soft-
@@ -217,6 +268,7 @@ function clampTimeoutMs(value) {
217
268
  return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, Math.floor(value)));
218
269
  }
219
270
  export async function httpFetch(url, options = {}) {
271
+ const startedAt = Date.now();
220
272
  let target;
221
273
  try {
222
274
  target = new URL(url);
@@ -241,12 +293,14 @@ export async function httpFetch(url, options = {}) {
241
293
  }
242
294
  const startHost = target.hostname.replace(/^\[|\]$/g, "");
243
295
  const ownedLoopback = Boolean(options.iOwnThis && isLoopbackHost(startHost));
296
+ const authorizedAddresses = new Map();
244
297
  // SSRF and engagement-policy checks run for the initial destination and
245
298
  // every redirect hop. Owned loopback (local app verify) skips engagement
246
299
  // hop checks — a leftover pentest scope must not block http://localhost:5173.
247
300
  const authorizeDestination = async (destination) => {
248
301
  const hostname = destination.hostname.replace(/^\[|\]$/g, "");
249
302
  const resolvedAddresses = await resolveHosts(hostname);
303
+ authorizedAddresses.set(hostname.toLowerCase(), resolvedAddresses);
250
304
  const blocked = isBlockedAddress(hostname) || resolvedAddresses.some(isBlockedAddress);
251
305
  const destLoopback = isLoopbackHost(hostname);
252
306
  if (blocked && !options.iOwnThis) {
@@ -285,18 +339,20 @@ export async function httpFetch(url, options = {}) {
285
339
  if (options.body !== undefined && method !== "GET" && method !== "HEAD") {
286
340
  init.body = options.body;
287
341
  }
288
- // Node undici: skip cert checks for authorized IP/lab HTTPS probes.
289
- if (insecureTls && target.protocol === "https:") {
290
- init.dispatcher = getInsecureTlsAgent();
291
- }
342
+ // The insecure dispatcher is selected per hop below because redirects may
343
+ // change scheme. A single shared dispatcher keeps authorized lab probes
344
+ // cheap without weakening unrelated requests.
292
345
  // Local dev: try localhost / 127.0.0.1 / ::1 until one accepts (Vite dual-stack).
293
346
  const urlCandidates = ownedLoopback && (method === "GET" || method === "HEAD")
294
347
  ? loopbackUrlCandidates(url)
295
348
  : [url];
296
349
  let response;
350
+ const pinnedAgents = [];
297
351
  let attempts = 0;
298
352
  let lastNetworkError;
353
+ // The active URL may advance through redirects within this attempt.
299
354
  let usedUrl = url;
355
+ let finalMethod = method;
300
356
  let redirectHops = [];
301
357
  let responseCleanup;
302
358
  const timeoutMs = clampTimeoutMs(options.timeoutMs);
@@ -334,31 +390,81 @@ export async function httpFetch(url, options = {}) {
334
390
  }
335
391
  try {
336
392
  let requestUrl = candidateUrl;
393
+ let requestMethod = method;
394
+ let requestBody = init.body;
395
+ let requestHeaders = new Headers(headers);
337
396
  let redirects = 0;
338
397
  redirectHops = [];
339
398
  for (;;) {
340
- response = await fetch(requestUrl, {
399
+ const hopInit = {
341
400
  ...init,
401
+ method: requestMethod,
402
+ headers: requestHeaders,
342
403
  signal: controller.signal,
343
- });
404
+ };
405
+ if (requestBody === undefined || requestBody === null) {
406
+ delete hopInit.body;
407
+ }
408
+ else {
409
+ hopInit.body = requestBody;
410
+ }
411
+ const hopUrl = new URL(requestUrl);
412
+ const hopHost = hopUrl.hostname.replace(/^\[|\]$/g, "").toLowerCase();
413
+ const pinned = authorizedAddresses.get(hopHost) ?? [];
414
+ if (!isLoopbackHost(hopHost)) {
415
+ const dispatcher = createPinnedAgent(pinned, insecureTls && hopUrl.protocol === "https:", options.iOwnThis === true);
416
+ pinnedAgents.push(dispatcher);
417
+ hopInit.dispatcher = dispatcher;
418
+ }
419
+ else if (insecureTls && hopUrl.protocol === "https:") {
420
+ // Owned loopback keeps the shared dispatcher so localhost can
421
+ // retain its IPv4/IPv6 fallback behavior.
422
+ hopInit.dispatcher = getInsecureTlsAgent();
423
+ }
424
+ else {
425
+ delete hopInit.dispatcher;
426
+ }
427
+ usedUrl = requestUrl;
428
+ response = await fetch(requestUrl, hopInit);
429
+ finalMethod = requestMethod;
344
430
  const location = response.headers.get("location");
345
431
  if (!(response.status >= 300 && response.status < 400 && location))
346
432
  break;
347
433
  if (redirects >= 10)
348
434
  throw new Error("Too many redirects (maximum 10)");
349
- const setCookies = typeof response.headers.getSetCookie === "function"
350
- ? response.headers.getSetCookie()
351
- : multiHeader(response.headers, "set-cookie");
352
435
  redirectHops.push({
353
436
  status: response.status,
437
+ statusText: response.statusText,
438
+ method: requestMethod,
354
439
  url: requestUrl,
355
440
  location,
356
- setCookies,
441
+ headers: snapshotHeaders(response.headers),
357
442
  });
358
443
  const next = new URL(location, requestUrl);
359
444
  const denial = await authorizeDestination(next);
360
445
  if (denial)
361
446
  throw new Error(denial);
447
+ if (!options.forwardSensitiveHeaders &&
448
+ new URL(requestUrl).origin !== next.origin) {
449
+ requestHeaders = new Headers(requestHeaders);
450
+ requestHeaders.delete("authorization");
451
+ requestHeaders.delete("proxy-authorization");
452
+ requestHeaders.delete("cookie");
453
+ }
454
+ // Match widely deployed user-agent redirect semantics while
455
+ // preserving methods for 307/308. This avoids replaying a POST
456
+ // body onto a 303 destination and records the actual method used.
457
+ if (requestMethod !== "HEAD" &&
458
+ (response.status === 303 ||
459
+ ((response.status === 301 || response.status === 302) &&
460
+ requestMethod === "POST"))) {
461
+ requestMethod = "GET";
462
+ requestBody = undefined;
463
+ requestHeaders = new Headers(requestHeaders);
464
+ requestHeaders.delete("content-length");
465
+ requestHeaders.delete("content-type");
466
+ requestHeaders.delete("transfer-encoding");
467
+ }
362
468
  await drainResponse(response);
363
469
  requestUrl = next.toString();
364
470
  redirects += 1;
@@ -410,6 +516,7 @@ export async function httpFetch(url, options = {}) {
410
516
  }
411
517
  }
412
518
  catch (error) {
519
+ await closeAgents(pinnedAgents);
413
520
  const tried = urlCandidates.length > 1
414
521
  ? ` (tried ${urlCandidates.join(" → ")})`
415
522
  : "";
@@ -421,15 +528,15 @@ export async function httpFetch(url, options = {}) {
421
528
  };
422
529
  }
423
530
  if (!response) {
531
+ await closeAgents(pinnedAgents);
424
532
  return {
425
533
  ok: false,
426
534
  output: "Network error: no response was received",
427
535
  exitCode: 1,
428
536
  };
429
537
  }
430
- const limit = options.maxBytes ?? DEFAULT_MAX_BYTES;
431
- const decoder = new TextDecoder("utf-8", { fatal: false });
432
- let collected = "";
538
+ const limit = clampCaptureBytes(options.maxBytes);
539
+ const chunks = [];
433
540
  let bytesRead = 0;
434
541
  let truncated = false;
435
542
  let bodyReadError;
@@ -471,9 +578,12 @@ export async function httpFetch(url, options = {}) {
471
578
  }
472
579
  break;
473
580
  }
581
+ const captured = value.byteLength > remaining
582
+ ? value.subarray(0, remaining)
583
+ : value;
584
+ chunks.push(Buffer.from(captured));
585
+ bytesRead += captured.byteLength;
474
586
  if (value.byteLength > remaining) {
475
- collected += decoder.decode(value.subarray(0, remaining), { stream: true });
476
- bytesRead += remaining;
477
587
  truncated = true;
478
588
  try {
479
589
  await reader.cancel();
@@ -483,10 +593,7 @@ export async function httpFetch(url, options = {}) {
483
593
  }
484
594
  break;
485
595
  }
486
- collected += decoder.decode(value, { stream: true });
487
- bytesRead += value.byteLength;
488
596
  }
489
- collected += decoder.decode();
490
597
  }
491
598
  finally {
492
599
  try {
@@ -497,9 +604,6 @@ export async function httpFetch(url, options = {}) {
497
604
  }
498
605
  }
499
606
  }
500
- else {
501
- collected = "";
502
- }
503
607
  }
504
608
  catch (error) {
505
609
  bodyReadError = error;
@@ -512,6 +616,7 @@ export async function httpFetch(url, options = {}) {
512
616
  const timedOut = bodyReadError instanceof Error &&
513
617
  bodyReadError.name === "AbortError" &&
514
618
  !options.signal?.aborted;
619
+ await closeAgents(pinnedAgents);
515
620
  return {
516
621
  ok: false,
517
622
  output: timedOut
@@ -520,11 +625,15 @@ export async function httpFetch(url, options = {}) {
520
625
  exitCode: timedOut ? 124 : options.signal?.aborted ? 130 : 1,
521
626
  };
522
627
  }
628
+ const capturedBody = Buffer.concat(chunks, bytesRead);
523
629
  const contentType = response.headers.get("content-type") ?? "";
524
- const isBinary = isBinaryContentType(contentType) || isBinaryContent(collected);
630
+ const decoded = decodeTextBody(capturedBody, contentType);
631
+ const isBinary = isBinaryContentType(contentType) ||
632
+ (!isTextualContentType(contentType) && isBinaryContent(capturedBody));
525
633
  const finalUrl = response.url || usedUrl;
526
634
  const evidence = formatHttpEvidence({
527
- method,
635
+ requestedMethod: method,
636
+ finalMethod,
528
637
  requestedUrl: url,
529
638
  usedUrl,
530
639
  finalUrl,
@@ -532,24 +641,36 @@ export async function httpFetch(url, options = {}) {
532
641
  statusText: response.statusText,
533
642
  attempts,
534
643
  bytesRead,
644
+ bodySha256: createHash("sha256").update(capturedBody).digest("hex"),
645
+ bodyCharset: decoded.charset,
646
+ charsetSource: decoded.charsetSource,
647
+ unsupportedCharset: decoded.unsupportedCharset,
535
648
  truncated,
536
649
  limit,
537
650
  contentType,
538
651
  headers: response.headers,
539
- body: collected,
652
+ body: decoded.text,
540
653
  isBinary,
541
654
  redirectHops,
542
655
  lastNetworkError,
543
656
  insecureTls,
544
- responseMode: options.responseMode ?? "readable",
657
+ forwardSensitiveHeaders: options.forwardSensitiveHeaders === true,
658
+ responseMode: options.responseMode ?? "raw",
545
659
  responsePart: options.responsePart ?? "full",
546
660
  });
547
661
  const output = selectOutput(evidence, options);
662
+ await closeAgents(pinnedAgents);
548
663
  return {
549
664
  ok: true,
550
665
  output,
551
666
  exitCode: 0,
552
667
  truncated: truncated || output !== evidence,
668
+ stats: {
669
+ bytesRead,
670
+ bytesDropped: 0,
671
+ linesRead: decoded.text.split("\n").length,
672
+ elapsedMs: Date.now() - startedAt,
673
+ },
553
674
  };
554
675
  }
555
676
  function multiHeader(headers, name) {
@@ -560,57 +681,100 @@ function multiHeader(headers, name) {
560
681
  });
561
682
  return out;
562
683
  }
684
+ /**
685
+ * Snapshot every response header exposed by the Fetch runtime. Header names
686
+ * and most repeat values are normalized by Fetch; Set-Cookie is recovered as
687
+ * separate lines when Node/undici exposes getSetCookie().
688
+ */
689
+ function snapshotHeaders(headers) {
690
+ const out = [];
691
+ headers.forEach((value, key) => {
692
+ if (key.toLowerCase() !== "set-cookie")
693
+ out.push([key, value]);
694
+ });
695
+ const setCookies = typeof headers.getSetCookie === "function"
696
+ ? headers.getSetCookie()
697
+ : multiHeader(headers, "set-cookie");
698
+ for (const value of setCookies)
699
+ out.push(["set-cookie", value]);
700
+ return out;
701
+ }
563
702
  /** Headers always kept in evidence (security / fingerprint / cookies). */
564
703
  const PRIORITY_HEADER_RE = /^(set-cookie|server|x-powered-by|x-aspnet|x-generator|x-frame-options|x-content-type-options|x-xss-protection|content-security-policy|content-type|location|www-authenticate|access-control-|strict-transport|referrer-policy|permissions-policy|cross-origin-|cache-control|via|cf-ray|x-request-id|x-amz-|x-served|link|allow|retry-after)$/i;
565
704
  function formatHttpEvidence(input) {
566
705
  const lines = [];
567
- lines.push(`${input.status} ${input.statusText} ${input.method} ${input.finalUrl}`);
706
+ lines.push(`${input.status} ${input.statusText} ${input.finalMethod} ${input.finalUrl}`);
568
707
  if (input.insecureTls) {
569
708
  lines.push("TLS: verification DISABLED (insecureTls=true) — hostname/cert not validated; authorized-test only.");
570
709
  }
571
710
  if (input.redirectHops.length > 0) {
572
711
  const chain = [
573
- ...input.redirectHops.map((h) => `${h.status} ${h.url} → ${h.location}`),
574
- `${input.status} ${input.finalUrl}`,
712
+ ...input.redirectHops.map((h) => `${h.status} ${h.method} ${h.url} → ${h.location}`),
713
+ `${input.status} ${input.finalMethod} ${input.finalUrl}`,
575
714
  ];
576
715
  lines.push(`redirects: ${chain.join(" → ")}`);
716
+ const crossedOrigin = input.redirectHops.some((hop) => {
717
+ try {
718
+ return new URL(hop.url).origin !== new URL(hop.location, hop.url).origin;
719
+ }
720
+ catch {
721
+ return false;
722
+ }
723
+ });
724
+ if (crossedOrigin) {
725
+ lines.push(input.forwardSensitiveHeaders
726
+ ? "redirect credentials: explicitly forwarded across origin change"
727
+ : "redirect credentials: Authorization, Proxy-Authorization, and Cookie stripped across origin change");
728
+ }
729
+ lines.push("");
730
+ lines.push("Redirect responses (headers runtime-normalized):");
577
731
  for (const hop of input.redirectHops) {
578
- if (hop.setCookies.length) {
579
- lines.push(` hop ${hop.status} set-cookie: ${hop.setCookies.join("; ")}`);
732
+ lines.push(` ${hop.status} ${hop.statusText} ${hop.method} ${hop.url}`, ` location: ${hop.location}`);
733
+ for (const [key, value] of hop.headers) {
734
+ lines.push(` ${key}: ${value}`);
580
735
  }
736
+ lines.push("");
737
+ }
738
+ }
739
+ const metaBits = [`attempts=${input.attempts}`];
740
+ if (input.responsePart === "headers") {
741
+ metaBits.push("bodyCapture=skipped(responsePart=headers)");
742
+ }
743
+ else {
744
+ metaBits.push(`bodyBytes=${input.bytesRead}`, `bodySha256=${input.bodySha256}`, `charset=${input.bodyCharset}(${input.charsetSource})`);
745
+ if (input.unsupportedCharset) {
746
+ metaBits.push(`unsupportedCharset=${input.unsupportedCharset}`);
581
747
  }
748
+ if (input.truncated)
749
+ metaBits.push(`truncated@${input.limit}`);
582
750
  }
583
- const metaBits = [
584
- `attempts=${input.attempts}`,
585
- `bytes=${input.bytesRead}`,
586
- ];
587
- if (input.truncated)
588
- metaBits.push(`truncated@${input.limit}`);
589
751
  if (input.usedUrl !== input.requestedUrl)
590
752
  metaBits.push(`via=${input.usedUrl}`);
591
- if (input.requestedUrl !== input.finalUrl && input.redirectHops.length === 0) {
592
- metaBits.push(`requested=${input.requestedUrl}`);
753
+ if (input.requestedMethod !== input.finalMethod ||
754
+ (input.requestedUrl !== input.finalUrl && input.redirectHops.length === 0)) {
755
+ metaBits.push(`requested=${input.requestedMethod} ${input.requestedUrl}`);
593
756
  }
594
757
  lines.push(metaBits.join(" "));
758
+ if (input.responsePart !== "headers") {
759
+ lines.push("capture: response-body bytes after Fetch transfer/content decoding; SHA-256 covers exactly the captured bytes");
760
+ }
595
761
  if (input.finalUrl.startsWith("https:") && !input.insecureTls) {
596
762
  lines.push("TLS: leaf cert not captured by http.fetch — use web.fetch with includeTls=true for fingerprint/SAN.");
597
763
  }
598
- // ALL response headers, always cheap vs body and critical for pentest.
599
- // Security/fingerprint headers first for scannability; remainder sorted after.
600
- const allHeaders = [];
601
- input.headers.forEach((v, k) => allHeaders.push([k, v]));
602
- allHeaders.sort(([a], [b]) => a.localeCompare(b));
764
+ // Fetch normalizes names and most repeated values. Preserve every exposed
765
+ // field, with individual Set-Cookie lines, and be explicit about the limit.
766
+ const allHeaders = snapshotHeaders(input.headers);
603
767
  const priority = [];
604
768
  const rest = [];
605
- for (const [k, v] of allHeaders) {
606
- const line = `${k}: ${v}`;
607
- if (PRIORITY_HEADER_RE.test(k))
769
+ for (const [key, value] of allHeaders) {
770
+ const line = `${key}: ${value}`;
771
+ if (PRIORITY_HEADER_RE.test(key))
608
772
  priority.push(line);
609
773
  else
610
774
  rest.push(line);
611
775
  }
612
776
  lines.push("");
613
- lines.push("Headers:");
777
+ lines.push("Final response headers (runtime-normalized; Set-Cookie preserved separately):");
614
778
  for (const line of priority)
615
779
  lines.push(line);
616
780
  for (const line of rest)
@@ -620,21 +784,21 @@ function formatHttpEvidence(input) {
620
784
  lines.push("");
621
785
  lines.push(`Tech hints: ${tech}`);
622
786
  }
623
- if (input.method !== "HEAD") {
787
+ if (input.finalMethod !== "HEAD") {
624
788
  lines.push("");
625
789
  lines.push("Body:");
626
790
  if (input.isBinary) {
627
- lines.push(`[Binary content (${input.contentType || "unknown content type"}) — raw body suppressed]`);
791
+ lines.push(`[Binary content (${input.contentType || "unknown content type"}) — textual rendering suppressed; use bodySha256 and headers as evidence]`);
628
792
  }
629
793
  else {
630
- const ct = input.contentType.toLowerCase();
631
- const isHtml = ct.includes("html");
632
- // Single representation: readable HTML OR raw captured source.
794
+ const isHtml = input.contentType.toLowerCase().includes("html");
795
+ // Raw is the forensic default and preserves source comments, tags,
796
+ // attributes, values, whitespace, and malformed markup after decoding.
633
797
  const bodyText = isHtml && input.responseMode === "readable"
634
- ? toReadableText(input.body) || input.body
798
+ ? toReadableText(input.body, input.finalUrl) || input.body
635
799
  : input.body;
636
800
  if (input.truncated) {
637
- lines.push(`${bodyText || "(empty)"}\n... (wire capture stopped at ${input.limit.toLocaleString()} bytes — raise maxBytes for more body)`);
801
+ lines.push(`${bodyText || "(empty)"}\n... (capture stopped at ${input.limit.toLocaleString()} decoded response-body bytes — raise maxBytes for more body)`);
638
802
  }
639
803
  else {
640
804
  lines.push(bodyText || "(empty)");
@@ -700,6 +864,11 @@ function deriveTechHints(headers, body, contentType) {
700
864
  }
701
865
  return bits.join("; ");
702
866
  }
867
+ function clampCaptureBytes(value) {
868
+ if (value === undefined || !Number.isFinite(value))
869
+ return DEFAULT_MAX_BYTES;
870
+ return Math.max(0, Math.min(MAX_CAPTURE_BYTES, Math.floor(value)));
871
+ }
703
872
  function clampRetries(value) {
704
873
  if (!Number.isFinite(value))
705
874
  return DEFAULT_RETRIES;
@@ -713,10 +882,10 @@ function sleep(ms) {
713
882
  }
714
883
  async function drainResponse(response) {
715
884
  try {
716
- await response.arrayBuffer();
885
+ await response.body?.cancel();
717
886
  }
718
887
  catch {
719
- // Best effort only; retrying is more important than draining.
888
+ // Best effort only; redirect/retry can proceed after a socket-close race.
720
889
  }
721
890
  }
722
891
  function isBinaryContentType(contentType) {
@@ -734,19 +903,29 @@ function isBinaryContentType(contentType) {
734
903
  }
735
904
  return false;
736
905
  }
737
- function isBinaryContent(text) {
738
- const sample = text.slice(0, 2048);
739
- if (sample.includes("\u0000"))
740
- return true;
906
+ function isTextualContentType(contentType) {
907
+ const ct = contentType.toLowerCase().split(";", 1)[0]?.trim() ?? "";
908
+ return (ct.startsWith("text/") ||
909
+ ct === "image/svg+xml" ||
910
+ ct === "application/json" ||
911
+ ct.endsWith("+json") ||
912
+ ct === "application/xml" ||
913
+ ct.endsWith("+xml") ||
914
+ ct === "application/javascript" ||
915
+ ct === "application/x-javascript" ||
916
+ ct === "application/x-www-form-urlencoded");
917
+ }
918
+ function isBinaryContent(body) {
919
+ const sample = body.subarray(0, 2_048);
741
920
  let nonPrintableCount = 0;
742
- for (let i = 0; i < sample.length; i++) {
743
- const code = sample.charCodeAt(i);
744
- // Control chars other than tab (9), lf (10), cr (13)
745
- if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
746
- nonPrintableCount++;
747
- if (nonPrintableCount > 5) {
921
+ for (const byte of sample) {
922
+ if (byte === 0)
923
+ return true;
924
+ // Control bytes other than tab (9), lf (10), cr (13).
925
+ if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
926
+ nonPrintableCount += 1;
927
+ if (nonPrintableCount > 5)
748
928
  return true;
749
- }
750
929
  }
751
930
  }
752
931
  return false;