@animalabs/agent-framework 0.22.4 → 0.23.1

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 (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -4
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -8,8 +8,10 @@
8
8
  * not fetch third-party origins at all (deferred with declared reference
9
9
  * origins); the connection credential is applied as an Authorization
10
10
  * header by this code alone and never surfaces to model, script, or URI.
11
- * - **scheme allowlist (§7.1, vector 11):** https; http only when the dialed
12
- * origin itself is plaintext (a ws:// server references its own origin).
11
+ * - **scheme allowlist (§7.1, vector 11):** the uri's own scheme is http: or
12
+ * https:, and since origins compare with their schemes, http only from a
13
+ * server dialed in plaintext (ws://), https only from one dialed over TLS
14
+ * (wss://).
13
15
  * - **no redirect traversal (§7.2, vector 12):** any 3xx refuses.
14
16
  * - **actual-byte ceiling (§7.3 list, vector 10):** streamed with abort the
15
17
  * moment real octets exceed the ceiling, regardless of claimed sizeBytes.
@@ -30,9 +32,11 @@
30
32
  */
31
33
  import { createHash } from 'node:crypto';
32
34
  import type { ReferenceRecord } from './references.js';
33
- import { EXPIRY_REASON, isReferenceExpired, referenceExpiry, REFERENCE_LIMITS, sanitizeLabel, UNBOUND_REASON } from './references.js';
35
+ import { boundedForLog, describeCause } from './errors.js';
36
+ import { EXPIRY_REASON, noAddressReason, referenceExpiry, REFERENCE_LIMITS, sanitizeLabel, UNBOUND_REASON } from './references.js';
34
37
 
35
- /** Per-fetch hard ceiling on actual bytes (overridable per server / call). */
38
+ /** Per-fetch hard ceiling on actual bytes. A call can lower it for itself
39
+ * (fetch_reference's max_bytes, which it clamps to this); nothing raises it. */
36
40
  export const DEFAULT_FETCH_MAX_BYTES = 256 * 1024 * 1024;
37
41
  /** Eager (autofetch) threshold: at most what could have shipped inline. */
38
42
  export const DEFAULT_EAGER_FETCH_MAX_BYTES = 256 * 1024;
@@ -41,29 +45,120 @@ export const DEFAULT_EAGER_FETCH_MAX_BYTES = 256 * 1024;
41
45
  * kept. */
42
46
  export const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
43
47
  const FETCH_TIMEOUT_MS = 60_000;
44
- /** What a refusal by the per-server budget adds. The total lives as long as
45
- * this fetcher, one per framework, so only someone running the host renews
46
- * it, by starting a new one, or raises the budget (the server's config's
47
- * autofetch.maxTotalBytes, which each fetch reads afresh). */
48
- const BUDGET_LASTS = "the budget doesn't renew on its own, so calling again won't change that unless whoever runs this host raises or renews it";
49
48
  const EAGER_FETCH_TIMEOUT_MS = 10_000;
50
49
 
50
+ // What calling again can do, as each failure's remedy says it. The fetcher
51
+ // doesn't know where a reference came from (a tool result, a push, a channel
52
+ // message), so none names a tool, and fetch_reference's max_bytes is named
53
+ // only where a larger one can fetch it.
54
+ const NOTHING_TO_DO = "Calling again won't change that.";
55
+ /** The registry keys a record by its uri, so a fresh reference to the same
56
+ * uri arrives as this very id and refreshes its record. */
57
+ const FRESH_REFERENCE = "Once a fresh reference to it arrives, fetching may work; until then, fetching it again won't.";
58
+ const TRY_LATER = 'Trying again later may work.';
59
+ /** A request that failed before an answer: a refused or reset connection
60
+ * can clear, and a cause that repeats on every try (a port fetch refuses, a
61
+ * name that doesn't resolve, a certificate this host doesn't trust) is
62
+ * whoever runs the host's to change, so the remedy names both. */
63
+ const REQUEST_FAILED = "Trying again later may work; if it fails the same way, whoever runs this host can check its connection to the server.";
64
+ const CREDENTIALS = "Calling again won't change that; whoever runs this host can check its credentials for the server.";
65
+ const FRESH_OR_CREDENTIALS = "Once a fresh reference to it arrives, fetching may work; if that's refused too, whoever runs this host can check its credentials for the server.";
66
+ /** The total lives as long as this fetcher, one per framework, so only
67
+ * someone running the host renews it, by starting a new one, or raises the
68
+ * budget (the server's config's autofetch.maxTotalBytes, which each fetch
69
+ * reads afresh). */
70
+ const BUDGET = "The budget doesn't renew on its own, so calling again won't change that unless whoever runs this host raises or renews it.";
71
+ /** The host's own ceiling: fetch_reference clamps max_bytes to it, and no
72
+ * config sets another. */
73
+ const HOST_CEILING = "No max_bytes or setting lifts that limit, so calling again won't change it.";
74
+ /** The resolver reads the host's server configs as they are at each fetch. */
75
+ const SERVER_BACK = "Calling again won't change that unless whoever runs this host adds that server back.";
76
+
51
77
  export interface ResolvedServerBinding {
52
- /** The url the connection was dialed to (ws/wss/http/https). */
78
+ /** The url the host dials the server at. The framework's resolver gives only
79
+ * a ws:// or wss:// url its WebSocket transport dials; the origin check
80
+ * reads ws as http and wss as https. */
53
81
  url: string;
54
82
  /** Connection credential, applied host-side as Authorization: Bearer. */
55
83
  token?: string;
56
84
  autofetch?: { maxBytes?: number; maxTotalBytes?: number };
57
85
  }
58
86
 
59
- export interface FetchOutcome {
60
- ok: boolean;
61
- /** Mount-prefixed workspace path of the saved payload (on success). */
62
- path?: string;
63
- bytes?: number;
64
- mimeType?: string;
65
- digestVerified?: boolean;
66
- error?: string;
87
+ /** What the host knows of a server a reference came from: its binding, or
88
+ * 'no-address' for one the host doesn't reach over a network, such as a
89
+ * stdio server, which has no address to dial. A server the host doesn't
90
+ * know resolves to undefined. */
91
+ export type ServerResolution = ResolvedServerBinding | 'no-address';
92
+
93
+ /** Why a fetch failed, or would be refused before dialing. */
94
+ export interface Refusal {
95
+ /** What happened, as a clause that completes "couldn't fetch <ref>: ",
96
+ * whatever it's about: fetch_reference's sentence, save_recent_image's,
97
+ * and the eager and script-demand fetches' log lines read it. */
98
+ error: string;
99
+ /** What calling again can do, as a sentence. fetch_reference and
100
+ * save_recent_image show it; the eager and script-demand fetches log the
101
+ * error alone. */
102
+ remedy: string;
103
+ }
104
+
105
+ /** A fetch's result: the saved copy, or why it failed, which every failure
106
+ * says with both a clause and a remedy. The fields the other side lacks are
107
+ * declared undefined, so a caller can read them before checking ok. */
108
+ export type FetchOutcome =
109
+ | {
110
+ ok: true;
111
+ /** Mount-prefixed workspace path of the saved payload. */
112
+ path: string;
113
+ bytes?: number;
114
+ mimeType?: string;
115
+ digestVerified?: boolean;
116
+ error?: undefined;
117
+ remedy?: undefined;
118
+ }
119
+ | ({ ok: false; path?: undefined; bytes?: undefined; mimeType?: undefined; digestVerified?: undefined } & Refusal);
120
+
121
+ const refused = (error: string, remedy: string): { refusal: Refusal } => ({ refusal: { error, remedy } });
122
+ const fail = (error: string, remedy: string): FetchOutcome => ({ ok: false, error, remedy });
123
+
124
+ /** What calling again can do about a server's HTTP status. A later try can
125
+ * work after a timeout, a conflict or lock the server's state can clear, a
126
+ * request it calls too early, a rate limit or a server's passing trouble,
127
+ * though not after 501 or 505, which say the server doesn't support the
128
+ * request. A 401 is about this host's credentials for the server, which a
129
+ * fresh reference doesn't change, and a 403 can be either those or this
130
+ * reference. The rest of 4xx is about this reference, which a fresh one may
131
+ * not share. */
132
+ function statusRemedy(status: number): string {
133
+ if (status === 501 || status === 505) return NOTHING_TO_DO;
134
+ if (status === 408 || status === 409 || status === 423 || status === 425 || status === 429 || status >= 500) return TRY_LATER;
135
+ if (status === 401) return CREDENTIALS;
136
+ if (status === 403) return FRESH_OR_CREDENTIALS;
137
+ return FRESH_REFERENCE;
138
+ }
139
+
140
+ /** What a download stopped at a call's own ceiling, below the host's, can do.
141
+ * Its whole size is unknown, only that it's past what arrived, and a fetch
142
+ * again reads it from the start, within the host's ceiling and what the
143
+ * server's budget has left after this one's bytes. The read loop stops a
144
+ * download past the host's ceiling before it asks, so what arrived is within
145
+ * it, and only the budget can be what binds. */
146
+ function largerMaxBytes(received: number, left: number, server: string): string {
147
+ const most = Math.min(DEFAULT_FETCH_MAX_BYTES, left);
148
+ if (received <= most) return `It's at least ${received} bytes; a larger max_bytes, up to ${most}, may fetch it.`;
149
+ return `It's at least ${received} bytes, more than the ${left} bytes left of this host's fetch budget for ${server}. ${BUDGET}`;
150
+ }
151
+
152
+ /** What a thrown fetch says went wrong. fetch's own "fetch failed" says only
153
+ * that, so its cause's words come first. A cause from trying each of a
154
+ * host's addresses (an AggregateError) has no words of its own, so its
155
+ * attempts' stand in. */
156
+ function whyFetchFailed(e: unknown): string {
157
+ const source = (e as { cause?: unknown } | null | undefined)?.cause ?? e;
158
+ const own = describeCause(source);
159
+ if (own !== '') return own;
160
+ const attempts = (source as { errors?: unknown } | null | undefined)?.errors;
161
+ return Array.isArray(attempts) && attempts.length > 0 ? attempts.map(describeCause).join('; ') : describeCause(e);
67
162
  }
68
163
 
69
164
  /** Magic-byte sniff for the formats this pipeline actually moves. The
@@ -96,6 +191,15 @@ const EXT_BY_MIME = new Map<string, string>(Object.entries({
96
191
  'application/pdf': 'pdf', 'application/zip': 'zip',
97
192
  }));
98
193
 
194
+ /** A server's address as it's configured, its scheme and host, for a
195
+ * clause: never its userinfo, path or query. URL's own origin is 'null' for
196
+ * a scheme that isn't special, which normalizedOrigin accepts. The caller
197
+ * has already parsed it. */
198
+ function configuredOrigin(url: string): string {
199
+ const u = new URL(url);
200
+ return `${u.protocol}//${u.host}`;
201
+ }
202
+
99
203
  /** ws→http / wss→https with default ports elided, for origin comparison. */
100
204
  export function normalizedOrigin(url: string): string | null {
101
205
  try {
@@ -118,56 +222,148 @@ export class ReferenceFetcher {
118
222
  private inFlight = new Map<string, Promise<FetchOutcome>>();
119
223
 
120
224
  constructor(
121
- private readonly resolveServer: (serverId: string) => ResolvedServerBinding | undefined,
225
+ private readonly resolveServer: (serverId: string) => ServerResolution | undefined,
122
226
  /** Persist the payload; returns a mount-prefixed path or null when no
123
227
  * writable workspace exists. File name is host-generated (§7.3). */
124
228
  private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
125
229
  ) {}
126
230
 
127
- /** Would the eager (autofetch) default fetch this record? Not one the
128
- * fetcher refuses as expired, which would only take an eager slot. It
129
- * takes the eager limit of the server the fetch would dial. */
231
+ /** Would the eager (autofetch) default fetch this record? Not one the fetch
232
+ * would refuse before dialing, asked as the eager fetch asks it, with the
233
+ * default ceiling, so such a record takes no eager slot; and only one whose
234
+ * claimed size is within the eager limit of the server it dials. For one
235
+ * whose fetch is already under way, which the eager fetch would join as
236
+ * fetch() does, only the server and its eager limit enter: the expiry,
237
+ * the budget and the ceiling were checked when that fetch began. */
130
238
  isEagerEligible(record: ReferenceRecord): boolean {
131
239
  if (record.fetchedPath) return false;
132
- if (isReferenceExpired(record.testimony)) return false;
133
- const server = this.serverFor(record);
134
- if ('refusal' in server) return false;
135
- const cap = server.binding.autofetch?.maxBytes ?? DEFAULT_EAGER_FETCH_MAX_BYTES;
240
+ const check = this.inFlight.has(record.refId) ? this.serverFor(record) : this.beforeDial(record);
241
+ if ('refusal' in check) return false;
242
+ const cap = check.binding.autofetch?.maxBytes ?? DEFAULT_EAGER_FETCH_MAX_BYTES;
136
243
  return record.testimony.sizeBytes !== undefined && record.testimony.sizeBytes <= cap;
137
244
  }
138
245
 
139
246
  /** The server to dial for a record (§6.2): the first, in the order they
140
247
  * delivered it, whose address is on the uri's origin. When none is, the
141
- * first server's refusal (unknown to this host, or the origin check's),
142
- * and for a record no server delivered, that it's unbound. */
143
- private serverFor(record: ReferenceRecord): { serverId: string; binding: ResolvedServerBinding } | { refusal: string } {
144
- let refusal: string | undefined;
248
+ * first server's refusal (unknown to this host, with no address to dial,
249
+ * or the origin check's), and for a record no server delivered, that it's
250
+ * unbound. */
251
+ private serverFor(record: ReferenceRecord): { serverId: string; binding: ResolvedServerBinding } | { refusal: Refusal } {
252
+ let refusal: { refusal: Refusal } | undefined;
145
253
  for (const serverId of record.serverIds) {
146
254
  const binding = this.resolveServer(serverId);
147
255
  if (!binding) {
148
- refusal ??= `unknown server: ${serverId}`;
256
+ refusal ??= refused(`this host no longer has the "${serverId}" server it came from`, SERVER_BACK);
257
+ continue;
258
+ }
259
+ if (binding === 'no-address') {
260
+ refusal ??= refused(noAddressReason(serverId), NOTHING_TO_DO);
149
261
  continue;
150
262
  }
151
- const offOrigin = this.originCheck(record, binding);
263
+ const offOrigin = this.originCheck(record, binding, serverId);
152
264
  if (offOrigin === null) return { serverId, binding };
153
265
  refusal ??= offOrigin;
154
266
  }
155
- return { refusal: refusal ?? `reference ${UNBOUND_REASON}` };
267
+ // A later delivery of the same uri by a server binds this record.
268
+ return refusal ?? refused(`it's ${UNBOUND_REASON}`, FRESH_REFERENCE);
156
269
  }
157
270
 
158
- /** §6.1/§7.1 policy check; returns a refusal reason or null. */
159
- private originCheck(record: ReferenceRecord, binding: ResolvedServerBinding): string | null {
271
+ /** Every check the fetch makes before it dials, in its order: the server
272
+ * (none, unknown, no address, an address of its own or the reference's
273
+ * that doesn't parse, a reference address carrying a user name or
274
+ * password, a scheme other than http: or https:, off the uri's origin),
275
+ * the expiry, a claimed size over the host's own ceiling, the server's
276
+ * spent budget, a claimed size over a call's ceiling. For a record with no
277
+ * saved copy and no fetch under way, the fetch, `refusal()` and the eager
278
+ * check all ask this, so what save_recent_image says is the reason the
279
+ * fetch would refuse with, and no eager slot goes to a fetch that would be
280
+ * refused. */
281
+ private beforeDial(record: ReferenceRecord, opts?: { maxBytes?: number }):
282
+ { refusal: Refusal } | { serverId: string; binding: ResolvedServerBinding; ceiling: number; budget: number } {
283
+ const server = this.serverFor(record);
284
+ if ('refusal' in server) return server;
285
+ const { serverId, binding } = server;
286
+ const t = record.testimony;
287
+ const expiry = referenceExpiry(t);
288
+ if (expiry) return refused(`the reference is ${EXPIRY_REASON[expiry]}`, FRESH_REFERENCE);
289
+
290
+ const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
291
+ // A claim past the host's own ceiling is refused first: nothing lifts
292
+ // it, not even a budget whoever runs the host raises.
293
+ if (t.sizeBytes !== undefined && t.sizeBytes > DEFAULT_FETCH_MAX_BYTES) {
294
+ return refused(`it claims ${t.sizeBytes} bytes, over the ${DEFAULT_FETCH_MAX_BYTES} bytes this host fetches at most`, HOST_CEILING);
295
+ }
296
+ // The budget is checked before a call's ceiling, here and while reading:
297
+ // a larger max_bytes lifts that ceiling, and nothing the caller does lifts
298
+ // the budget, so a fetch past both is told about the budget.
299
+ const budget = binding.autofetch?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
300
+ const used = this.totals.get(serverId) ?? 0;
301
+ if (used >= budget) {
302
+ return refused(`this host's fetch budget for the "${serverId}" server is used up (${used} of ${budget} bytes)`, BUDGET);
303
+ }
304
+ // Vector 4: a claim already over the ceiling refuses without dialing. One
305
+ // that's also over what's left of the budget is told about the budget,
306
+ // which no max_bytes lifts. A claim over what's left but within the
307
+ // ceiling still dials, and the budget stops it at its first chunk past.
308
+ if (t.sizeBytes !== undefined && t.sizeBytes > ceiling) {
309
+ if (t.sizeBytes > budget - used) {
310
+ return refused(
311
+ `it claims ${t.sizeBytes} bytes, more than the ${budget - used} bytes left of this host's fetch budget for the "${serverId}" server`,
312
+ BUDGET,
313
+ );
314
+ }
315
+ // Within what's left of the budget and the host's ceiling, so a
316
+ // ceiling of the call's own is what a larger max_bytes lifts.
317
+ return refused(`it claims ${t.sizeBytes} bytes, over the ${ceiling} bytes this call allows`,
318
+ `Pass max_bytes of at least ${t.sizeBytes} to fetch it.`);
319
+ }
320
+ return { serverId, binding, ceiling, budget };
321
+ }
322
+
323
+ /** Why a fetch of this record would be refused before dialing, with the
324
+ * default ceiling, which a fetch_reference call naming no max_bytes has.
325
+ * Null where the fetch would serve a saved copy, join a fetch of it
326
+ * already under way, or dial (where it can still fail), the same three
327
+ * steps fetch() takes. */
328
+ refusal(record: ReferenceRecord): Refusal | null {
329
+ if (record.fetchedPath || this.inFlight.has(record.refId)) return null;
330
+ const check = this.beforeDial(record);
331
+ return 'refusal' in check ? check.refusal : null;
332
+ }
333
+
334
+ /** §6.1/§7.1 policy check; returns a refusal or null. */
335
+ private originCheck(record: ReferenceRecord, binding: ResolvedServerBinding, serverId: string): { refusal: Refusal } | null {
160
336
  const refOrigin = normalizedOrigin(record.testimony.uri);
337
+ if (!refOrigin) return refused("its address doesn't parse as a URL", NOTHING_TO_DO);
338
+ // A server's address that doesn't parse leaves nothing to dial. The
339
+ // framework's resolver gives only an address its transport dials, so this
340
+ // guards other resolvers.
161
341
  const dialedOrigin = normalizedOrigin(binding.url);
162
- if (!refOrigin) return 'reference uri does not parse';
163
- if (!dialedOrigin) return 'server url does not parse';
164
- const scheme = refOrigin.split('//')[0];
165
- if (scheme !== 'https:' && scheme !== 'http:') return `scheme not allowed: ${scheme}`;
166
- if (refOrigin !== dialedOrigin) {
167
- return `third-party origin (${refOrigin} != dialed ${dialedOrigin}); not fetched by this host`;
342
+ if (!dialedOrigin) {
343
+ return refused(`the "${serverId}" server's address doesn't parse as a URL, so there's nowhere to fetch it from`,
344
+ "Calling again won't change that unless whoever runs this host corrects the server's address.");
168
345
  }
169
- if (scheme === 'http:' && dialedOrigin.startsWith('https:')) {
170
- return 'plaintext reference from a secure origin';
346
+ const refUrl = new URL(record.testimony.uri);
347
+ // A user name or password in the uri is a credential embedded in it,
348
+ // which RFC-005 §6.1 forbids a server to mint and lets a host refuse
349
+ // (vector 6). This host neither sends nor repeats it: fetch refuses such
350
+ // a url with a message that holds it whole.
351
+ if (refUrl.username !== '' || refUrl.password !== '') {
352
+ return refused("its address carries a user name or password, which this host doesn't send", NOTHING_TO_DO);
353
+ }
354
+ // The uri's own scheme, not its origin's: an origin reads ws: as http:
355
+ // and wss: as https:, and fetch refuses a ws: or wss: uri ('unknown
356
+ // scheme') before connecting, which would read as a failed request a
357
+ // later try can mend.
358
+ const scheme = refUrl.protocol;
359
+ if (scheme !== 'https:' && scheme !== 'http:') {
360
+ return refused(`its address uses ${scheme}, and this host fetches only http: and https: addresses`, NOTHING_TO_DO);
361
+ }
362
+ // Origins compare with their schemes, so this also refuses an http:
363
+ // reference from a server dialed over TLS (wss://).
364
+ if (refOrigin !== dialedOrigin) {
365
+ return refused(`it points to ${refOrigin}, not to the "${serverId}" server's own address (${configuredOrigin(binding.url)}), ` +
366
+ 'and this host fetches only from the server itself', NOTHING_TO_DO);
171
367
  }
172
368
  return null;
173
369
  }
@@ -188,46 +384,23 @@ export class ReferenceFetcher {
188
384
 
189
385
  private async doFetch(record: ReferenceRecord, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<FetchOutcome> {
190
386
  const t = record.testimony;
191
- const server = this.serverFor(record);
192
- if ('refusal' in server) return { ok: false, error: server.refusal };
193
- const { serverId, binding } = server;
194
- const expiry = referenceExpiry(t);
195
- if (expiry) return { ok: false, error: `reference ${EXPIRY_REASON[expiry]}` };
196
-
197
- const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
198
- // The budget is checked before the ceiling, here and while reading: a
199
- // larger max_bytes lifts the ceiling, and nothing the caller does lifts
200
- // the budget, so a fetch past both is told about the budget.
201
- const budget = binding.autofetch?.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
202
- const used = this.totals.get(serverId) ?? 0;
203
- if (used >= budget) {
204
- return { ok: false, error: `per-server fetch budget exhausted (${used}/${budget} bytes); ${BUDGET_LASTS}` };
205
- }
206
- // Vector 4: a claim already over the ceiling refuses without dialing. One
207
- // that's also over what's left of the budget is told about the budget,
208
- // which no max_bytes lifts. A claim over what's left but within the
209
- // ceiling still dials, and the budget stops it at its first chunk past.
210
- if (t.sizeBytes !== undefined && t.sizeBytes > ceiling) {
211
- if (t.sizeBytes > budget - used) {
212
- return {
213
- ok: false,
214
- error: `claimed ${t.sizeBytes} bytes exceeds what's left of the per-server fetch budget (${used}/${budget} bytes); ${BUDGET_LASTS}`,
215
- };
216
- }
217
- return { ok: false, error: `claimed ${t.sizeBytes} bytes exceeds fetch ceiling ${ceiling}` };
218
- }
387
+ const check = this.beforeDial(record, opts);
388
+ if ('refusal' in check) return { ok: false, ...check.refusal };
389
+ const { serverId, binding, ceiling, budget } = check;
390
+ const server = `the "${serverId}" server`;
219
391
 
392
+ const timeoutMs = opts?.timeoutMs ?? FETCH_TIMEOUT_MS;
220
393
  const controller = new AbortController();
221
- const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? FETCH_TIMEOUT_MS);
394
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
222
395
  try {
223
396
  const headers: Record<string, string> = {};
224
397
  if (binding.token) headers.authorization = `Bearer ${binding.token}`;
225
398
  const resp = await fetch(t.uri, { headers, redirect: 'manual', signal: controller.signal });
226
399
  if (resp.status >= 300 && resp.status < 400) {
227
- return { ok: false, error: `redirect refused (HTTP ${resp.status}) — no redirect traversal` };
400
+ return fail(`${server} answered HTTP ${resp.status}, a redirect, and this host doesn't follow redirects`, NOTHING_TO_DO);
228
401
  }
229
- if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
230
- if (!resp.body) return { ok: false, error: 'no response body' };
402
+ if (!resp.ok) return fail(`${server} answered HTTP ${resp.status}`, statusRemedy(resp.status));
403
+ if (!resp.body) return fail(`${server} answered without any content`, FRESH_REFERENCE);
231
404
 
232
405
  const hash = createHash('sha256');
233
406
  const chunks: Buffer[] = [];
@@ -241,13 +414,24 @@ export class ReferenceFetcher {
241
414
  // this server read the same total, so none spends what another took.
242
415
  const total = (this.totals.get(serverId) ?? 0) + value.byteLength;
243
416
  this.totals.set(serverId, total);
417
+ // The limit nothing lifts first, as before dialing: the host's
418
+ // ceiling, then the budget, then a call's own ceiling.
419
+ if (received > DEFAULT_FETCH_MAX_BYTES) {
420
+ controller.abort();
421
+ return fail(`the download had reached ${received} bytes, past the ${DEFAULT_FETCH_MAX_BYTES} bytes this host fetches at most, ` +
422
+ 'so it was stopped and nothing was kept', HOST_CEILING);
423
+ }
244
424
  if (total > budget) {
245
425
  controller.abort();
246
- return { ok: false, error: `aborted: per-server fetch budget exhausted (${total}/${budget} bytes); ${BUDGET_LASTS}` };
426
+ // The server's running total, as the next fetch's refusal gives it:
427
+ // other fetches through the server may have spent part of it.
428
+ return fail(`this host's fetch budget for ${server} ran out during the download (${total} of ${budget} bytes), ` +
429
+ 'so it was stopped and nothing was kept', BUDGET);
247
430
  }
248
431
  if (received > ceiling) {
249
432
  controller.abort();
250
- return { ok: false, error: `aborted: actual bytes exceeded ceiling at ${received}` };
433
+ return fail(`the download had reached ${received} bytes, past the ${ceiling} bytes this call allows, ` +
434
+ 'so it was stopped and nothing was kept', largerMaxBytes(received, budget - total, server));
251
435
  }
252
436
  const buf = Buffer.from(value);
253
437
  hash.update(buf);
@@ -260,7 +444,11 @@ export class ReferenceFetcher {
260
444
  const got = 'sha256:' + hash.digest('base64url');
261
445
  if (got !== t.digest) {
262
446
  // Vector 5: never presented as the described content.
263
- return { ok: false, error: `digest mismatch (claimed ${t.digest.slice(0, 20)}…, got ${got.slice(0, 20)}…)` };
447
+ return fail(
448
+ `the bytes that arrived don't match the reference's digest (${t.digest.slice(0, 20)}…, against ${got.slice(0, 20)}…), ` +
449
+ "so they weren't kept",
450
+ "Trying again may work; if it fails the same way, fetching may work once a fresh reference to it arrives.",
451
+ );
264
452
  }
265
453
  digestVerified = true;
266
454
  }
@@ -275,8 +463,21 @@ export class ReferenceFetcher {
275
463
  const observedMime = sniffMime(data) ?? headerMime ?? claimedMime ?? 'application/octet-stream';
276
464
  const ext = EXT_BY_MIME.get(observedMime) ?? EXT_BY_MIME.get(headerMime ?? '') ?? EXT_BY_MIME.get(claimedMime ?? '') ?? 'bin';
277
465
  // §7.3: host-generated storage name; the server's `name` is display-only.
278
- const path = await this.saveFile(`${record.refId}.${ext}`, data, observedMime);
279
- if (path === null) return { ok: false, error: 'no writable workspace mount to store the payload' };
466
+ // A null from the save hook covers no workspace, no writable mount and
467
+ // a failed write, and the hook logs which. A throw is the save's failure
468
+ // too, not the fetch's, so it's logged here.
469
+ const fileName = `${record.refId}.${ext}`;
470
+ let path: string | null;
471
+ try {
472
+ path = await this.saveFile(fileName, data, observedMime);
473
+ } catch (e) {
474
+ console.error(`[refs] couldn't store ${fileName}: ${boundedForLog(describeCause(e))}`);
475
+ path = null;
476
+ }
477
+ if (path === null) {
478
+ return fail("it was fetched, but this host couldn't store it in its workspace, so nothing was kept",
479
+ 'Calling again is likely to fail the same way; whoever runs this host can see why in its log.');
480
+ }
280
481
 
281
482
  record.fetchedPath = path;
282
483
  record.verifiedBytes = data.length;
@@ -284,8 +485,13 @@ export class ReferenceFetcher {
284
485
  record.digestVerified = digestVerified;
285
486
  return { ok: true, path, bytes: data.length, mimeType: observedMime, digestVerified };
286
487
  } catch (e) {
287
- const msg = (e as Error).name === 'AbortError' ? 'fetch timed out or aborted' : (e as Error).message;
288
- return { ok: false, error: msg };
488
+ // The timer is the only abort that throws: the budget and the ceiling
489
+ // abort the download and return at once.
490
+ if ((e as Error)?.name === 'AbortError') {
491
+ const seconds = timeoutMs / 1000;
492
+ return fail(`it took longer than ${seconds} second${seconds === 1 ? '' : 's'}`, TRY_LATER);
493
+ }
494
+ return fail(`the request to ${server} failed: ${boundedForLog(whyFetchFailed(e))}`, REQUEST_FAILED);
289
495
  } finally {
290
496
  clearTimeout(timer);
291
497
  }