@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.51

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 (264) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/assets/pugi-prozr2-mascot.ansi +9 -0
  4. package/bin/run.js +33 -1
  5. package/dist/commands/jobs-watch.js +201 -0
  6. package/dist/commands/jobs.js +15 -0
  7. package/dist/commands/smoke.js +133 -0
  8. package/dist/core/agent-progress/cleanup.js +134 -0
  9. package/dist/core/agent-progress/schema.js +144 -0
  10. package/dist/core/agent-progress/writer.js +101 -0
  11. package/dist/core/artifact-chain/dispatcher.js +148 -0
  12. package/dist/core/artifact-chain/exporter.js +164 -0
  13. package/dist/core/artifact-chain/state.js +243 -0
  14. package/dist/core/artifact-chain/steps.js +169 -0
  15. package/dist/core/auth/ensure-authenticated.js +129 -0
  16. package/dist/core/auth/env-provider.js +238 -0
  17. package/dist/core/auto-update/channels.js +122 -0
  18. package/dist/core/auto-update/checker.js +241 -0
  19. package/dist/core/auto-update/state.js +235 -0
  20. package/dist/core/bare-mode/index.js +107 -0
  21. package/dist/core/bash-classifier.js +400 -4
  22. package/dist/core/checkpoint/resumer.js +149 -0
  23. package/dist/core/checkpoint/rewinder.js +291 -0
  24. package/dist/core/codegraph/decision-store.js +248 -0
  25. package/dist/core/codegraph/detect-repo.js +459 -0
  26. package/dist/core/codegraph/install.js +134 -0
  27. package/dist/core/codegraph/offer-hook.js +220 -0
  28. package/dist/core/compact/auto-trigger.js +96 -0
  29. package/dist/core/compact/buffer-rewriter.js +115 -0
  30. package/dist/core/compact/summarizer.js +208 -0
  31. package/dist/core/compact/token-counter.js +108 -0
  32. package/dist/core/consensus/diff-capture.js +112 -3
  33. package/dist/core/context/index.js +7 -0
  34. package/dist/core/context/markdown-traverse.js +255 -0
  35. package/dist/core/cost/rate-card.js +129 -0
  36. package/dist/core/cost/tracker.js +221 -0
  37. package/dist/core/denial-tracking/index.js +8 -0
  38. package/dist/core/denial-tracking/state.js +264 -0
  39. package/dist/core/diagnostics/probe-runner.js +93 -0
  40. package/dist/core/diagnostics/probes/api.js +46 -0
  41. package/dist/core/diagnostics/probes/auth.js +86 -0
  42. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  43. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  44. package/dist/core/diagnostics/probes/config.js +72 -0
  45. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  46. package/dist/core/diagnostics/probes/disk.js +81 -0
  47. package/dist/core/diagnostics/probes/git.js +65 -0
  48. package/dist/core/diagnostics/probes/hooks.js +118 -0
  49. package/dist/core/diagnostics/probes/mcp.js +75 -0
  50. package/dist/core/diagnostics/probes/node.js +59 -0
  51. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  52. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  53. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  54. package/dist/core/diagnostics/probes/session.js +74 -0
  55. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  56. package/dist/core/diagnostics/probes/workspace.js +63 -0
  57. package/dist/core/diagnostics/types.js +70 -0
  58. package/dist/core/dispatch/cache-cleanup.js +197 -0
  59. package/dist/core/dispatch/cache-handoff.js +295 -0
  60. package/dist/core/edits/dispatch.js +218 -2
  61. package/dist/core/edits/journal.js +199 -0
  62. package/dist/core/edits/layer-d-ast.js +557 -14
  63. package/dist/core/edits/verify-hook.js +273 -0
  64. package/dist/core/edits/worktree.js +322 -0
  65. package/dist/core/engine/anvil-client.js +115 -5
  66. package/dist/core/engine/auto-compact.js +179 -0
  67. package/dist/core/engine/budgets.js +155 -0
  68. package/dist/core/engine/context-prefix.js +155 -0
  69. package/dist/core/engine/intent.js +260 -0
  70. package/dist/core/engine/native-pugi.js +897 -211
  71. package/dist/core/engine/prompts.js +88 -2
  72. package/dist/core/engine/strip-internal-fields.js +124 -0
  73. package/dist/core/engine/tool-bridge.js +1045 -36
  74. package/dist/core/feedback/queue.js +177 -0
  75. package/dist/core/feedback/submitter.js +145 -0
  76. package/dist/core/file-cache.js +113 -1
  77. package/dist/core/hooks/events.js +44 -0
  78. package/dist/core/hooks/index.js +15 -0
  79. package/dist/core/hooks/registry.js +213 -0
  80. package/dist/core/hooks/runner.js +236 -0
  81. package/dist/core/hooks/v2/event-emitter.js +115 -0
  82. package/dist/core/hooks/v2/executor.js +282 -0
  83. package/dist/core/hooks/v2/index.js +25 -0
  84. package/dist/core/hooks/v2/lifecycle.js +104 -0
  85. package/dist/core/hooks/v2/loader.js +216 -0
  86. package/dist/core/hooks/v2/matcher.js +125 -0
  87. package/dist/core/hooks/v2/trust.js +143 -0
  88. package/dist/core/hooks/v2/types.js +86 -0
  89. package/dist/core/lsp/cache.js +105 -0
  90. package/dist/core/lsp/client.js +776 -0
  91. package/dist/core/lsp/language-detect.js +66 -0
  92. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  93. package/dist/core/mcp/client.js +75 -6
  94. package/dist/core/mcp/http-server.js +553 -0
  95. package/dist/core/mcp/orchestrator-tools.js +662 -0
  96. package/dist/core/mcp/permission.js +190 -0
  97. package/dist/core/mcp/registry.js +24 -2
  98. package/dist/core/mcp/server-tools.js +219 -0
  99. package/dist/core/mcp/server.js +397 -0
  100. package/dist/core/memory/dual-write.js +416 -0
  101. package/dist/core/memory/phase1-kinds.js +20 -0
  102. package/dist/core/memory-sync/queue.js +158 -0
  103. package/dist/core/onboarding/ensure-initialized.js +133 -0
  104. package/dist/core/onboarding/marker.js +111 -0
  105. package/dist/core/onboarding/telemetry-state.js +108 -0
  106. package/dist/core/output-style/presets.js +176 -0
  107. package/dist/core/output-style/state.js +185 -0
  108. package/dist/core/path-security.js +284 -2
  109. package/dist/core/permissions/auto-classifier.js +124 -0
  110. package/dist/core/permissions/circuit-breaker.js +83 -0
  111. package/dist/core/permissions/gate.js +278 -0
  112. package/dist/core/permissions/index.js +20 -0
  113. package/dist/core/permissions/mode.js +174 -0
  114. package/dist/core/permissions/state.js +241 -0
  115. package/dist/core/permissions/tool-class.js +93 -0
  116. package/dist/core/prd-check/parser.js +215 -0
  117. package/dist/core/prd-check/reporter.js +127 -0
  118. package/dist/core/prd-check/session-review.js +557 -0
  119. package/dist/core/prd-check/verifiers.js +223 -0
  120. package/dist/core/pugi-md/context-injector.js +76 -0
  121. package/dist/core/pugi-md/walk-up.js +207 -0
  122. package/dist/core/release-notes/parser.js +241 -0
  123. package/dist/core/release-notes/state.js +116 -0
  124. package/dist/core/repl/history.js +11 -1
  125. package/dist/core/repl/model-pricing.js +135 -0
  126. package/dist/core/repl/session.js +1897 -37
  127. package/dist/core/repl/slash-commands.js +430 -15
  128. package/dist/core/repl/store/session-store.js +31 -2
  129. package/dist/core/repl/workspace-context.js +22 -0
  130. package/dist/core/repo-map/build.js +125 -0
  131. package/dist/core/repo-map/cache.js +185 -0
  132. package/dist/core/repo-map/extractor.js +254 -0
  133. package/dist/core/repo-map/formatter.js +145 -0
  134. package/dist/core/repo-map/scanner.js +211 -0
  135. package/dist/core/retry-budget/budget.js +284 -0
  136. package/dist/core/retry-budget/index.js +5 -0
  137. package/dist/core/session.js +92 -0
  138. package/dist/core/settings.js +80 -0
  139. package/dist/core/share/formatter.js +271 -0
  140. package/dist/core/share/redactor.js +221 -0
  141. package/dist/core/share/uploader.js +267 -0
  142. package/dist/core/skills/defaults.js +457 -0
  143. package/dist/core/smoke/headless-driver.js +174 -0
  144. package/dist/core/smoke/orchestrator.js +194 -0
  145. package/dist/core/smoke/runner.js +238 -0
  146. package/dist/core/smoke/scenario-parser.js +316 -0
  147. package/dist/core/subagents/dispatcher-real.js +600 -0
  148. package/dist/core/subagents/dispatcher.js +113 -24
  149. package/dist/core/subagents/index.js +18 -5
  150. package/dist/core/subagents/isolation-matrix.js +213 -0
  151. package/dist/core/subagents/spawn.js +19 -4
  152. package/dist/core/telemetry/emitter.js +229 -0
  153. package/dist/core/telemetry/queue.js +251 -0
  154. package/dist/core/theme/context.js +91 -0
  155. package/dist/core/theme/presets.js +228 -0
  156. package/dist/core/theme/state.js +181 -0
  157. package/dist/core/todos/invariant.js +10 -0
  158. package/dist/core/todos/state.js +177 -0
  159. package/dist/core/transport/version-interceptor.js +166 -0
  160. package/dist/core/vim/keymap.js +288 -0
  161. package/dist/core/vim/state.js +92 -0
  162. package/dist/core/worktree-manager/cleanup.js +123 -0
  163. package/dist/core/worktree-manager/manager.js +303 -0
  164. package/dist/index.js +28 -0
  165. package/dist/runtime/bootstrap.js +190 -0
  166. package/dist/runtime/cli.js +3241 -343
  167. package/dist/runtime/commands/cancel.js +231 -0
  168. package/dist/runtime/commands/chain.js +489 -0
  169. package/dist/runtime/commands/codegraph-status.js +227 -0
  170. package/dist/runtime/commands/compact.js +297 -0
  171. package/dist/runtime/commands/cost.js +199 -0
  172. package/dist/runtime/commands/delegate.js +242 -11
  173. package/dist/runtime/commands/dispatch.js +126 -0
  174. package/dist/runtime/commands/doctor.js +412 -0
  175. package/dist/runtime/commands/feedback.js +184 -0
  176. package/dist/runtime/commands/hooks.js +184 -0
  177. package/dist/runtime/commands/lsp.js +368 -0
  178. package/dist/runtime/commands/mcp.js +879 -0
  179. package/dist/runtime/commands/memory.js +508 -0
  180. package/dist/runtime/commands/model.js +237 -0
  181. package/dist/runtime/commands/onboarding.js +275 -0
  182. package/dist/runtime/commands/patch.js +128 -0
  183. package/dist/runtime/commands/permissions.js +112 -0
  184. package/dist/runtime/commands/plan.js +143 -0
  185. package/dist/runtime/commands/prd-check.js +285 -0
  186. package/dist/runtime/commands/redo-blob-store.js +92 -0
  187. package/dist/runtime/commands/redo.js +361 -0
  188. package/dist/runtime/commands/release-notes.js +229 -0
  189. package/dist/runtime/commands/repo-map.js +95 -0
  190. package/dist/runtime/commands/report.js +299 -0
  191. package/dist/runtime/commands/resume.js +118 -0
  192. package/dist/runtime/commands/review-consensus.js +17 -2
  193. package/dist/runtime/commands/rewind.js +333 -0
  194. package/dist/runtime/commands/sessions.js +163 -0
  195. package/dist/runtime/commands/share.js +316 -0
  196. package/dist/runtime/commands/status.js +186 -0
  197. package/dist/runtime/commands/stickers.js +82 -0
  198. package/dist/runtime/commands/style.js +194 -0
  199. package/dist/runtime/commands/theme.js +196 -0
  200. package/dist/runtime/commands/undo.js +32 -0
  201. package/dist/runtime/commands/update.js +289 -0
  202. package/dist/runtime/commands/vim.js +140 -0
  203. package/dist/runtime/commands/worktree.js +177 -0
  204. package/dist/runtime/commands/worktrees.js +155 -0
  205. package/dist/runtime/headless-repl.js +195 -0
  206. package/dist/runtime/headless.js +543 -0
  207. package/dist/runtime/load-hooks-or-exit.js +71 -0
  208. package/dist/runtime/plan-decompose.js +531 -0
  209. package/dist/runtime/version.js +65 -0
  210. package/dist/tools/agent-tool.js +229 -0
  211. package/dist/tools/apply-patch.js +556 -0
  212. package/dist/tools/ask-user-question.js +213 -0
  213. package/dist/tools/ask-user.js +115 -0
  214. package/dist/tools/bash.js +203 -4
  215. package/dist/tools/file-tools.js +85 -14
  216. package/dist/tools/lsp-tools.js +189 -0
  217. package/dist/tools/mcp-tool.js +260 -0
  218. package/dist/tools/multi-edit.js +361 -0
  219. package/dist/tools/powershell.js +268 -0
  220. package/dist/tools/registry.js +51 -0
  221. package/dist/tools/skill-tool.js +96 -0
  222. package/dist/tools/tasks.js +208 -0
  223. package/dist/tools/todo-write.js +184 -0
  224. package/dist/tools/web-fetch.js +147 -2
  225. package/dist/tools/web-search.js +458 -0
  226. package/dist/tui/agent-progress-card.js +111 -0
  227. package/dist/tui/agent-tree.js +10 -0
  228. package/dist/tui/ask-modal.js +2 -2
  229. package/dist/tui/ask-user-question-prompt.js +192 -0
  230. package/dist/tui/compact-banner.js +81 -0
  231. package/dist/tui/conversation-pane.js +82 -8
  232. package/dist/tui/cost-table.js +111 -0
  233. package/dist/tui/doctor-table.js +46 -0
  234. package/dist/tui/feedback-prompt.js +156 -0
  235. package/dist/tui/input-box.js +218 -3
  236. package/dist/tui/markdown-render.js +4 -4
  237. package/dist/tui/onboarding-wizard.js +240 -0
  238. package/dist/tui/permissions-picker.js +86 -0
  239. package/dist/tui/render.js +35 -0
  240. package/dist/tui/repl-render.js +313 -35
  241. package/dist/tui/repl-splash-art.js +1 -1
  242. package/dist/tui/repl-splash-mascot.js +32 -8
  243. package/dist/tui/repl-splash.js +2 -2
  244. package/dist/tui/repl.js +85 -5
  245. package/dist/tui/splash.js +1 -1
  246. package/dist/tui/status-bar.js +94 -16
  247. package/dist/tui/status-table.js +7 -0
  248. package/dist/tui/stickers-art.js +136 -0
  249. package/dist/tui/style-table.js +28 -0
  250. package/dist/tui/theme-table.js +29 -0
  251. package/dist/tui/thinking-spinner.js +123 -0
  252. package/dist/tui/tool-stream-pane.js +52 -3
  253. package/dist/tui/update-banner.js +27 -2
  254. package/dist/tui/vim-input.js +267 -0
  255. package/dist/tui/welcome-banner.js +107 -0
  256. package/dist/tui/welcome-data.js +293 -0
  257. package/docs/examples/codegraph.mcp.json +10 -0
  258. package/package.json +13 -7
  259. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  260. package/test/scenarios/compact-force.scenario.txt +11 -0
  261. package/test/scenarios/identity.scenario.txt +11 -0
  262. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  263. package/test/scenarios/walkback.scenario.txt +12 -0
  264. package/dist/core/engine/compaction-hook.js +0 -154
@@ -34,7 +34,7 @@
34
34
  * Brand voice: brief / dispatch / ship / sentinel only. The
35
35
  * brandbook §08 forbidden-word list applies — see CLAUDE.md.
36
36
  */
37
- import { request } from 'undici';
37
+ import { request, Agent } from 'undici';
38
38
  import { Readability } from '@mozilla/readability';
39
39
  import { parseHTML } from 'linkedom';
40
40
  import TurndownService from 'turndown';
@@ -45,6 +45,72 @@ let activeLookup = async (hostname) => await dnsLookup(hostname, { all: true, ve
45
45
  export function _setLookupForTests(fn) {
46
46
  activeLookup = fn ?? (async (hostname) => await dnsLookup(hostname, { all: true, verbatim: true }));
47
47
  }
48
+ /**
49
+ * β1b #62 — DNS rebinding guard via pinned-address Dispatcher.
50
+ *
51
+ * Without this, the SSRF guard's `dns.lookup` and undici's `request()`
52
+ * connect(2) each issue independent DNS queries. A hostile resolver
53
+ * can answer "8.8.8.8" the first time (passes the SSRF guard) and
54
+ * "127.0.0.1" the second time (kernel connects to local metadata).
55
+ *
56
+ * Fix: resolve once, validate, then pin the resolved address into a
57
+ * per-call `Agent` via `connect.lookup`. The connect() path no longer
58
+ * touches DNS — it uses the IP we already approved.
59
+ *
60
+ * Test seam: spec suite uses MockAgent as the global dispatcher; the
61
+ * MockAgent path does not exercise real connect(), so pinning is both
62
+ * pointless and would break the MockAgent stub. Specs flip
63
+ * `_disablePinnedDispatcherForTests(true)` in beforeEach to keep the
64
+ * MockAgent flow intact while production hits the pinned path.
65
+ */
66
+ let pinnedDispatcherDisabled = false;
67
+ export function _disablePinnedDispatcherForTests(disabled) {
68
+ pinnedDispatcherDisabled = disabled;
69
+ }
70
+ /**
71
+ * Build a per-call undici Agent that always returns the pre-resolved
72
+ * `address` from its connect.lookup hook. Returns `undefined` when the
73
+ * test flag disabled pinning — caller then falls back to the global
74
+ * dispatcher (MockAgent or production default).
75
+ */
76
+ async function buildPinnedDispatcher(hostname) {
77
+ if (pinnedDispatcherDisabled)
78
+ return undefined;
79
+ // Skip pinning when hostname is already a literal IP — there is no
80
+ // DNS step to race in that case.
81
+ if (isIPv4(hostname) || isIPv6(hostname))
82
+ return undefined;
83
+ let answers;
84
+ try {
85
+ answers = await activeLookup(hostname);
86
+ }
87
+ catch {
88
+ // Best-effort — fall through without pinning; the SSRF guard will
89
+ // emit the canonical DNS-lookup-failed error on the caller's path.
90
+ return undefined;
91
+ }
92
+ const pinned = answers[0];
93
+ if (!pinned)
94
+ return undefined;
95
+ // β1b r1: close the DNS rebinding window the original guard could
96
+ // not see. `validateHostnameForFetch` already ran one lookup; the
97
+ // call above is a SECOND lookup whose answer feeds the pin. A
98
+ // hostile resolver can return a public address to the guard and a
99
+ // private address here — re-validate the pinned literal before we
100
+ // hand it to the Agent. Throws so the caller surfaces a security
101
+ // refusal rather than silently dispatching to the wrong host.
102
+ const ipCheck = validateIpLiteralForFetch(pinned.address, pinned.family);
103
+ if (ipCheck !== null) {
104
+ throw new Error(`ssrf_pinned_address_blocked: ${ipCheck}`);
105
+ }
106
+ return new Agent({
107
+ connect: {
108
+ lookup: (_h, _opts, cb) => {
109
+ cb(null, pinned.address, pinned.family);
110
+ },
111
+ },
112
+ });
113
+ }
48
114
  const FETCH_TIMEOUT_MS = 10_000;
49
115
  const MAX_RESPONSE_BYTES = 5 * 1024 * 1024; // 5 MiB
50
116
  const MAX_REDIRECTS = 5;
@@ -231,6 +297,42 @@ function ipv4IsBlocked(ip) {
231
297
  }
232
298
  return false;
233
299
  }
300
+ /**
301
+ * Validate a single IP literal (v4 or v6) against the SSRF blocklist.
302
+ * Pure synchronous check — no DNS. Returns `null` on success (safe to
303
+ * connect), an error string when the address is blocked or not a
304
+ * recognized IP literal.
305
+ *
306
+ * Used by the pinned-dispatcher path (web-fetch + web-search) to
307
+ * RE-VALIDATE the address actually pinned into `connect.lookup` AFTER
308
+ * the second DNS round-trip. Without this check the original SSRF
309
+ * guard's lookup answers can diverge from the lookup answers that
310
+ * feed the pin (hostile resolver flips public→private between calls);
311
+ * re-checking the pinned literal closes that window.
312
+ *
313
+ * Exported for spec coverage.
314
+ */
315
+ export function validateIpLiteralForFetch(address, family) {
316
+ if (!address)
317
+ return 'empty address';
318
+ // Trust family hint when present (LookupAddress.family is 4 or 6),
319
+ // otherwise infer from the string shape.
320
+ const isV4 = family === 4 || (family === undefined && isIPv4(address));
321
+ const isV6 = family === 6 || (family === undefined && isIPv6(address));
322
+ if (isV4) {
323
+ if (ipv4IsBlocked(address)) {
324
+ return `IP ${address} is in a blocked range (SSRF guard)`;
325
+ }
326
+ return null;
327
+ }
328
+ if (isV6) {
329
+ if (ipv6IsBlocked(address)) {
330
+ return `IPv6 ${address} is in a blocked range (SSRF guard)`;
331
+ }
332
+ return null;
333
+ }
334
+ return `address ${address} is not a recognized IPv4/IPv6 literal`;
335
+ }
234
336
  /**
235
337
  * Resolve `hostname` via dns.lookup and reject if any answer maps to
236
338
  * a private/loopback/link-local/CGNAT range. Returns `null` on success
@@ -395,10 +497,34 @@ export async function webFetchTool(input, ctx) {
395
497
  let currentUrl = parsedUrl;
396
498
  let hops = 0;
397
499
  const controller = new AbortController();
500
+ // β1b #62: per-hop pinned Agent so the post-lookup connect(2) cannot
501
+ // be redirected to a private IP by a hostile resolver. Built lazily
502
+ // per hop because each redirect target may resolve to a different
503
+ // host. `undefined` falls back to the global dispatcher (spec
504
+ // MockAgent or production default), preserving the existing test
505
+ // path. The current Agent is closed at end-of-call so we do not leak
506
+ // open connections.
507
+ let activeAgent;
508
+ const closeActiveAgent = async () => {
509
+ if (activeAgent) {
510
+ try {
511
+ await activeAgent.close();
512
+ }
513
+ catch {
514
+ /* ignore — agent already closed */
515
+ }
516
+ activeAgent = undefined;
517
+ }
518
+ };
398
519
  try {
399
520
  while (true) {
521
+ // β1b #62: refresh the pinned Agent for the current hop.
522
+ await closeActiveAgent();
523
+ const hopHost = currentUrl.hostname.replace(/^\[|\]$/g, '');
524
+ activeAgent = await buildPinnedDispatcher(hopHost);
400
525
  response = await request(currentUrl.toString(), {
401
526
  method: 'GET',
527
+ ...(activeAgent ? { dispatcher: activeAgent } : {}),
402
528
  headers: {
403
529
  'user-agent': USER_AGENT,
404
530
  accept: 'text/html,application/xhtml+xml',
@@ -436,6 +562,7 @@ export async function webFetchTool(input, ctx) {
436
562
  /* socket already closed — nothing to do */
437
563
  }
438
564
  }
565
+ await closeActiveAgent();
439
566
  return { ok: false, error: `Exceeded ${MAX_REDIRECTS} redirect hops.` };
440
567
  }
441
568
  // Drain prior body so the socket can be reused.
@@ -445,9 +572,11 @@ export async function webFetchTool(input, ctx) {
445
572
  nextUrl = new URL(locStr, currentUrl);
446
573
  }
447
574
  catch {
575
+ await closeActiveAgent();
448
576
  return { ok: false, error: `Invalid redirect target: ${locStr}` };
449
577
  }
450
578
  if (nextUrl.protocol !== 'http:' && nextUrl.protocol !== 'https:') {
579
+ await closeActiveAgent();
451
580
  return {
452
581
  ok: false,
453
582
  error: `Refusing redirect to unsupported scheme ${nextUrl.protocol}.`,
@@ -456,6 +585,7 @@ export async function webFetchTool(input, ctx) {
456
585
  const nextHost = nextUrl.hostname.replace(/^\[|\]$/g, '');
457
586
  const guard = await validateHostnameForFetch(nextHost);
458
587
  if (guard) {
588
+ await closeActiveAgent();
459
589
  return { ok: false, error: `SSRF refused on redirect: ${guard}` };
460
590
  }
461
591
  currentUrl = nextUrl;
@@ -465,13 +595,23 @@ export async function webFetchTool(input, ctx) {
465
595
  }
466
596
  }
467
597
  catch (error) {
598
+ await closeActiveAgent();
468
599
  const message = error instanceof Error ? error.message : String(error);
600
+ // β1b r1: the pinned-dispatcher path throws `ssrf_pinned_address_blocked: …`
601
+ // when the second DNS lookup answered a private IP. Surface that as a
602
+ // first-class SSRF refusal so callers (and specs) can match on it
603
+ // without grovelling through `Fetch failed:` prefixes.
604
+ if (message.startsWith('ssrf_pinned_address_blocked')) {
605
+ return { ok: false, error: `SSRF refused: ${message}` };
606
+ }
469
607
  return { ok: false, error: `Fetch failed: ${message}` };
470
608
  }
471
609
  if (!response) {
610
+ await closeActiveAgent();
472
611
  return { ok: false, error: 'No response received.' };
473
612
  }
474
613
  if (response.statusCode < 200 || response.statusCode >= 300) {
614
+ await closeActiveAgent();
475
615
  return { ok: false, error: `HTTP ${response.statusCode} from ${currentUrl.toString()}` };
476
616
  }
477
617
  // content-length is advisory — never trust it for the size cap, but
@@ -489,6 +629,7 @@ export async function webFetchTool(input, ctx) {
489
629
  catch {
490
630
  /* ignore */
491
631
  }
632
+ await closeActiveAgent();
492
633
  return {
493
634
  ok: false,
494
635
  error: `Declared content-length ${n} exceeds ${MAX_RESPONSE_BYTES} byte cap.`,
@@ -499,11 +640,14 @@ export async function webFetchTool(input, ctx) {
499
640
  const contentType = Array.isArray(contentTypeRaw) ? contentTypeRaw[0] : contentTypeRaw;
500
641
  const mime = typeof contentType === 'string' ? contentType.split(';')[0]?.trim().toLowerCase() ?? '' : '';
501
642
  if (!ALLOWED_CONTENT_TYPES.includes(mime)) {
643
+ await closeActiveAgent();
502
644
  return { ok: false, error: `Disallowed content-type ${mime || '(none)'}; only HTML/XHTML/text.` };
503
645
  }
504
646
  const bodyResult = await readBodyWithCap(response.body, controller);
505
- if (!bodyResult.ok)
647
+ if (!bodyResult.ok) {
648
+ await closeActiveAgent();
506
649
  return bodyResult;
650
+ }
507
651
  const html = bodyResult.buffer.toString('utf8');
508
652
  // linkedom is the lightweight DOM Readability needs; jsdom would
509
653
  // add ~3 MB to the install footprint for the same surface.
@@ -524,6 +668,7 @@ export async function webFetchTool(input, ctx) {
524
668
  `Source: ${safeSource}\n\n` +
525
669
  `${scrubbedMarkdown}\n` +
526
670
  `</untrusted-content-${nonce}>`;
671
+ await closeActiveAgent();
527
672
  return {
528
673
  ok: true,
529
674
  url: currentUrl.toString(),