@retinue/agentkit 0.2.0 → 0.3.0

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 (204) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/audio/openai.d.ts +36 -0
  3. package/dist/adapters/audio/openai.js +136 -0
  4. package/dist/adapters/bullmq/lock.d.ts +26 -2
  5. package/dist/adapters/memory/connections.d.ts +10 -0
  6. package/dist/adapters/memory/connections.js +87 -0
  7. package/dist/adapters/memory/graph.d.ts +17 -0
  8. package/dist/adapters/memory/graph.js +267 -0
  9. package/dist/adapters/memory/index.d.ts +3 -0
  10. package/dist/adapters/memory/index.js +3 -0
  11. package/dist/adapters/memory/rate-limit.d.ts +14 -0
  12. package/dist/adapters/memory/rate-limit.js +28 -0
  13. package/dist/adapters/memory/runtime.js +0 -0
  14. package/dist/adapters/postgres/connections.d.ts +15 -0
  15. package/dist/adapters/postgres/connections.js +134 -0
  16. package/dist/adapters/postgres/graph.d.ts +24 -0
  17. package/dist/adapters/postgres/graph.js +325 -0
  18. package/dist/adapters/postgres/index.d.ts +2 -0
  19. package/dist/adapters/postgres/index.js +2 -0
  20. package/dist/adapters/postgres/migrations.js +198 -0
  21. package/dist/adapters/postgres/run-store.js +26 -2
  22. package/dist/adapters/postgres/schema.d.ts +61 -2
  23. package/dist/adapters/postgres/schema.js +95 -10
  24. package/dist/adapters/redis/index.d.ts +1 -0
  25. package/dist/adapters/redis/index.js +1 -0
  26. package/dist/adapters/redis/rate-limit.d.ts +46 -0
  27. package/dist/adapters/redis/rate-limit.js +55 -0
  28. package/dist/adapters/supabase/index.d.ts +15 -0
  29. package/dist/adapters/supabase/index.js +15 -0
  30. package/dist/adapters/supabase/rls.js +33 -0
  31. package/dist/agents/agent.d.ts +78 -1
  32. package/dist/agents/agent.js +118 -5
  33. package/dist/agents/define.js +24 -4
  34. package/dist/agents/engine.d.ts +20 -0
  35. package/dist/agents/engine.js +177 -6
  36. package/dist/audio/index.d.ts +151 -0
  37. package/dist/audio/index.js +183 -0
  38. package/dist/connections/cipher.d.ts +103 -0
  39. package/dist/connections/cipher.js +141 -0
  40. package/dist/connections/index.d.ts +156 -0
  41. package/dist/connections/index.js +72 -0
  42. package/dist/connections/oauth/client.d.ts +84 -0
  43. package/dist/connections/oauth/client.js +141 -0
  44. package/dist/connections/oauth/index.d.ts +166 -0
  45. package/dist/connections/oauth/index.js +226 -0
  46. package/dist/connections/oauth/service.d.ts +90 -0
  47. package/dist/connections/oauth/service.js +132 -0
  48. package/dist/connections/pause.d.ts +68 -0
  49. package/dist/connections/pause.js +97 -0
  50. package/dist/connections/resolver.d.ts +59 -0
  51. package/dist/connections/resolver.js +138 -0
  52. package/dist/connections/resume.d.ts +64 -0
  53. package/dist/connections/resume.js +99 -0
  54. package/dist/connections/vault-cipher.d.ts +95 -0
  55. package/dist/connections/vault-cipher.js +206 -0
  56. package/dist/core/content-parts.d.ts +18 -2
  57. package/dist/core/content-parts.js +1 -0
  58. package/dist/core/context.d.ts +25 -0
  59. package/dist/core/errors.d.ts +1 -1
  60. package/dist/core/errors.js +15 -0
  61. package/dist/core/events.d.ts +44 -2
  62. package/dist/core/events.js +31 -2
  63. package/dist/core/validation.js +19 -0
  64. package/dist/entries/connections.d.ts +10 -0
  65. package/dist/entries/connections.js +10 -0
  66. package/dist/entries/knowledge.d.ts +8 -0
  67. package/dist/entries/knowledge.js +8 -0
  68. package/dist/entries/mcp-server.d.ts +9 -0
  69. package/dist/entries/mcp-server.js +9 -0
  70. package/dist/entries/runtime.d.ts +10 -0
  71. package/dist/entries/runtime.js +8 -0
  72. package/dist/entries/testing.d.ts +28 -0
  73. package/dist/entries/testing.js +28 -0
  74. package/dist/graphql/resolvers.d.ts +3 -1
  75. package/dist/graphql/resolvers.js +14 -0
  76. package/dist/knowledge/communities.d.ts +166 -0
  77. package/dist/knowledge/communities.js +377 -0
  78. package/dist/knowledge/graph-global.d.ts +153 -0
  79. package/dist/knowledge/graph-global.js +246 -0
  80. package/dist/knowledge/graph-retrieval.d.ts +150 -0
  81. package/dist/knowledge/graph-retrieval.js +303 -0
  82. package/dist/knowledge/graph.d.ts +223 -0
  83. package/dist/knowledge/graph.js +369 -0
  84. package/dist/knowledge/index.d.ts +24 -0
  85. package/dist/knowledge/index.js +36 -1
  86. package/dist/knowledge/retrieval.d.ts +26 -1
  87. package/dist/knowledge/retrieval.js +77 -1
  88. package/dist/mcp-server/index.d.ts +168 -0
  89. package/dist/mcp-server/index.js +175 -0
  90. package/dist/models/extraction.d.ts +73 -0
  91. package/dist/models/extraction.js +118 -0
  92. package/dist/models/index.d.ts +40 -1
  93. package/dist/models/index.js +22 -1
  94. package/dist/models/pricing.d.ts +2 -0
  95. package/dist/models/pricing.js +17 -1
  96. package/dist/models/provider-factory.js +9 -7
  97. package/dist/models/streaming.d.ts +89 -0
  98. package/dist/models/streaming.js +179 -2
  99. package/dist/persistence/index.d.ts +230 -0
  100. package/dist/persistence/index.js +2 -0
  101. package/dist/runtime/index.d.ts +1 -1
  102. package/dist/runtime/index.js +12 -0
  103. package/dist/runtime/worker.d.ts +36 -1
  104. package/dist/runtime/worker.js +37 -4
  105. package/dist/security/checklist.js +46 -0
  106. package/dist/server/bin.d.ts +19 -0
  107. package/dist/server/bin.js +226 -0
  108. package/dist/server/boot.d.ts +20 -2
  109. package/dist/server/boot.js +29 -3
  110. package/dist/server/cli.d.ts +13 -0
  111. package/dist/server/cli.js +7 -4
  112. package/dist/server/doctor.d.ts +74 -0
  113. package/dist/server/doctor.js +280 -0
  114. package/dist/skills/context.d.ts +61 -0
  115. package/dist/skills/context.js +107 -0
  116. package/dist/skills/index.d.ts +1 -0
  117. package/dist/skills/index.js +1 -0
  118. package/dist/telemetry/spans.js +5 -0
  119. package/dist/testing/conformance/artifact-exports.d.ts +20 -0
  120. package/dist/testing/conformance/artifact-exports.js +172 -0
  121. package/dist/testing/conformance/artifacts.d.ts +11 -0
  122. package/dist/testing/conformance/artifacts.js +316 -0
  123. package/dist/testing/conformance/audio.d.ts +38 -0
  124. package/dist/testing/conformance/audio.js +145 -0
  125. package/dist/testing/conformance/capability.d.ts +23 -0
  126. package/dist/testing/conformance/capability.js +27 -0
  127. package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
  128. package/dist/testing/conformance/checkpoint-store.js +72 -0
  129. package/dist/testing/conformance/connections.d.ts +14 -0
  130. package/dist/testing/conformance/connections.js +171 -0
  131. package/dist/testing/conformance/conversation-store.d.ts +10 -0
  132. package/dist/testing/conformance/conversation-store.js +60 -0
  133. package/dist/testing/conformance/evaluation.d.ts +10 -0
  134. package/dist/testing/conformance/evaluation.js +295 -0
  135. package/dist/testing/conformance/files.d.ts +19 -0
  136. package/dist/testing/conformance/files.js +454 -0
  137. package/dist/testing/conformance/flows.d.ts +16 -0
  138. package/dist/testing/conformance/flows.js +193 -0
  139. package/dist/testing/conformance/graph.d.ts +22 -0
  140. package/dist/testing/conformance/graph.js +500 -0
  141. package/dist/testing/conformance/hitl.d.ts +25 -0
  142. package/dist/testing/conformance/hitl.js +523 -0
  143. package/dist/testing/conformance/index.d.ts +163 -0
  144. package/dist/testing/conformance/index.js +321 -0
  145. package/dist/testing/conformance/invariants.d.ts +23 -0
  146. package/dist/testing/conformance/invariants.js +80 -0
  147. package/dist/testing/conformance/knowledge.d.ts +41 -0
  148. package/dist/testing/conformance/knowledge.js +592 -0
  149. package/dist/testing/conformance/parents.d.ts +50 -0
  150. package/dist/testing/conformance/parents.js +39 -0
  151. package/dist/testing/conformance/rate-limit.d.ts +25 -0
  152. package/dist/testing/conformance/rate-limit.js +71 -0
  153. package/dist/testing/conformance/records.d.ts +36 -0
  154. package/dist/testing/conformance/records.js +400 -0
  155. package/dist/testing/conformance/rollups.d.ts +30 -0
  156. package/dist/testing/conformance/rollups.js +425 -0
  157. package/dist/testing/conformance/run-coordinator.d.ts +27 -0
  158. package/dist/testing/conformance/run-coordinator.js +150 -0
  159. package/dist/testing/conformance/run-event-log.d.ts +9 -0
  160. package/dist/testing/conformance/run-event-log.js +138 -0
  161. package/dist/testing/conformance/run-store.d.ts +12 -0
  162. package/dist/testing/conformance/run-store.js +294 -0
  163. package/dist/testing/conformance/session-state.d.ts +38 -0
  164. package/dist/testing/conformance/session-state.js +198 -0
  165. package/dist/testing/conformance/usage-limits.d.ts +10 -0
  166. package/dist/testing/conformance/usage-limits.js +309 -0
  167. package/dist/testing/conformance.d.ts +8 -0
  168. package/dist/testing/conformance.js +8 -0
  169. package/dist/testing/memory-backend.d.ts +39 -0
  170. package/dist/testing/memory-backend.js +41 -0
  171. package/dist/testing/pglite.d.ts +55 -0
  172. package/dist/testing/pglite.js +78 -0
  173. package/dist/testing/stub-model.d.ts +58 -0
  174. package/dist/testing/stub-model.js +71 -0
  175. package/dist/testing/supabase-storage-double.d.ts +35 -0
  176. package/dist/testing/supabase-storage-double.js +101 -0
  177. package/dist/toolkit/http.d.ts +27 -0
  178. package/dist/toolkit/http.js +48 -0
  179. package/dist/toolkit/index.d.ts +27 -1
  180. package/dist/toolkit/index.js +24 -1
  181. package/dist/toolkit/mime.d.ts +97 -0
  182. package/dist/toolkit/mime.js +233 -0
  183. package/dist/toolkit/ssrf.d.ts +139 -0
  184. package/dist/toolkit/ssrf.js +349 -0
  185. package/dist/toolkit/vendor.d.ts +91 -0
  186. package/dist/toolkit/vendor.js +153 -0
  187. package/dist/tools/credentials.d.ts +265 -6
  188. package/dist/tools/credentials.js +318 -3
  189. package/dist/tools/define.d.ts +14 -0
  190. package/dist/tools/define.js +3 -0
  191. package/dist/tools/index.d.ts +21 -0
  192. package/dist/tools/index.js +7 -0
  193. package/dist/tools/library/audio.d.ts +63 -0
  194. package/dist/tools/library/audio.js +148 -0
  195. package/dist/tools/library/index.d.ts +8 -0
  196. package/dist/tools/library/index.js +7 -0
  197. package/dist/tools/registry.d.ts +33 -0
  198. package/dist/tools/registry.js +72 -2
  199. package/dist/usage/index.d.ts +21 -0
  200. package/dist/usage/index.js +1 -0
  201. package/dist/usage/rate-limit.d.ts +122 -0
  202. package/dist/usage/rate-limit.js +131 -0
  203. package/dist/usage/recorder.js +3 -0
  204. package/package.json +29 -2
@@ -0,0 +1,349 @@
1
+ /**
2
+ * SSRF, closed at the point of connection — REQ-055 (#237), task #238, AC-3.
3
+ *
4
+ * This is the substance of the package. An agent that fetches URLs a model chose is a server-side request
5
+ * forgery primitive unless something stops it, and the naive implementation — check the hostname, then call
6
+ * `fetch` — is defeated three separate ways:
7
+ *
8
+ * 1. **A private or link-local literal.** `http://169.254.169.254/latest/meta-data/iam/security-credentials/`
9
+ * returns cloud credentials in plain text on EC2. `[::ffff:169.254.169.254]` is the same address wearing a
10
+ * different hat, and so is `0177.0.0.1`.
11
+ * 2. **A public name that resolves to a private address.** `metadata.attacker.example` with an `A` record of
12
+ * `169.254.169.254` passes every hostname check ever written, because the hostname is genuinely public.
13
+ * 3. **A redirect.** The check ran on the URL that was requested, and the response says to go somewhere else.
14
+ *
15
+ * Each needs its own defence, and each has its own test.
16
+ *
17
+ * ## Why this resolves DNS itself rather than trusting a check-then-fetch
18
+ *
19
+ * Vector 2's obvious fix — resolve the name, look at the addresses, then call `fetch` — leaves a window: the
20
+ * fetch resolves the name a *second* time, and a DNS record with a one-second TTL can answer differently. That
21
+ * is DNS rebinding, it is not theoretical, and a check that a determined caller can lose a race against is a
22
+ * check that reports safety it does not provide.
23
+ *
24
+ * So the resolution and the connection are the same event. `node:https` accepts a `lookup` function, called at
25
+ * connect time with the hostname; this module supplies one that returns **only** an address it has just
26
+ * validated. There is no second resolution to poison. TLS still verifies the certificate against the hostname
27
+ * from the URL, so pinning the address costs nothing in transport security.
28
+ *
29
+ * This is why the package uses `node:https` rather than the shared `createHttpClient`: `fetch` has no way to
30
+ * say "connect to this address". The trade is written down in `check:transport`'s exemption list.
31
+ *
32
+ * ## What is deliberately *not* claimed
33
+ *
34
+ * This closes access to private network space. It does not make fetching arbitrary URLs safe in general — the
35
+ * content that comes back is still untrusted, which is a separate problem handled by the untrusted-content
36
+ * fence rather than here.
37
+ */
38
+ import { lookup as dnsLookup } from "node:dns";
39
+ import { request as httpsRequest } from "node:https";
40
+ import { request as httpRequest } from "node:http";
41
+ /**
42
+ * How this identifies itself, honestly.
43
+ *
44
+ * A real name and a URL that explains what it is, because the alternative — impersonating a browser — is the
45
+ * bot-detection evasion this package declines to do, and because a site that wants to refuse a crawler should
46
+ * be able to. Several sites answer a *missing* User-Agent with a 429 that is not a rate limit, so sending
47
+ * nothing is not an option either.
48
+ */
49
+ export const DEFAULT_USER_AGENT = "RetinueBot/1.0 (+https://retinue.dev/integrations/scrape)";
50
+ const v4Parts = (host) => {
51
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
52
+ if (match === null)
53
+ return null;
54
+ const parts = match.slice(1, 5).map(Number);
55
+ return parts.every((part) => part >= 0 && part <= 255) ? parts : null;
56
+ };
57
+ /**
58
+ * Whether an IPv4 address is somewhere a fetch must not go.
59
+ *
60
+ * Broader than "the RFC 1918 ranges", because the ranges that matter are the ones that reach something. In
61
+ * particular `100.64/10` (carrier-grade NAT) is where several hosting providers put internal services, and
62
+ * `0.0.0.0/8` is a well-known way of writing "this host" that a naive `127.` check misses entirely.
63
+ */
64
+ export const isPrivateV4 = (host) => {
65
+ const parts = v4Parts(host);
66
+ if (parts === null)
67
+ return false;
68
+ const [a = 0, b = 0] = parts;
69
+ if (a === 0)
70
+ return true; // "this network" — 0.0.0.0 reaches localhost on Linux
71
+ if (a === 10 || a === 127)
72
+ return true; // private, loopback
73
+ if (a === 169 && b === 254)
74
+ return true; // link-local, including cloud metadata
75
+ if (a === 172 && b >= 16 && b <= 31)
76
+ return true;
77
+ if (a === 192 && b === 168)
78
+ return true;
79
+ if (a === 192 && b === 0)
80
+ return true; // IETF protocol assignments, incl. 192.0.0.192
81
+ if (a === 100 && b >= 64 && b <= 127)
82
+ return true; // carrier-grade NAT
83
+ if (a === 198 && (b === 18 || b === 19))
84
+ return true; // benchmarking
85
+ if (a >= 224)
86
+ return true; // multicast and reserved — 224/4 and 240/4
87
+ return false;
88
+ };
89
+ /**
90
+ * Whether an IPv6 address is somewhere a fetch must not go.
91
+ *
92
+ * The mapped and embedded forms are the interesting part: `::ffff:169.254.169.254` and `::169.254.169.254` are
93
+ * both the metadata address, and `2002:a9fe:a9fe::` is it again inside a 6to4 prefix. A v6 check that does not
94
+ * look inside those is a v4 check with extra steps.
95
+ */
96
+ export const isPrivateV6 = (host) => {
97
+ const address = host.toLowerCase().replace(/^\[|\]$/g, "").split("%")[0] ?? "";
98
+ if (address === "" || address === "::" || address === "::1")
99
+ return true;
100
+ // An embedded IPv4 address — `::ffff:1.2.3.4`, `::1.2.3.4` — is judged as the IPv4 address it contains.
101
+ const embedded = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(address);
102
+ if (embedded?.[1] !== undefined)
103
+ return isPrivateV4(embedded[1]);
104
+ // The same thing written in hex: `::ffff:a9fe:a9fe`.
105
+ const hexMapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(address);
106
+ if (hexMapped !== null) {
107
+ const high = Number.parseInt(hexMapped[1] ?? "0", 16);
108
+ const low = Number.parseInt(hexMapped[2] ?? "0", 16);
109
+ return isPrivateV4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
110
+ }
111
+ // 6to4: `2002:<v4 in hex>::/48` routes to the embedded IPv4 address.
112
+ const sixToFour = /^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4}):/.exec(address);
113
+ if (sixToFour !== null) {
114
+ const high = Number.parseInt(sixToFour[1] ?? "0", 16);
115
+ const low = Number.parseInt(sixToFour[2] ?? "0", 16);
116
+ if (isPrivateV4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`))
117
+ return true;
118
+ }
119
+ const head = address.split(":")[0] ?? "";
120
+ const leading = Number.parseInt(head === "" ? "0" : head, 16);
121
+ if ((leading & 0xfe00) === 0xfc00)
122
+ return true; // fc00::/7 unique local
123
+ if ((leading & 0xffc0) === 0xfe80)
124
+ return true; // fe80::/10 link-local
125
+ if ((leading & 0xff00) === 0xff00)
126
+ return true; // ff00::/8 multicast
127
+ return false;
128
+ };
129
+ export const isPrivateAddress = (address) => address.includes(":") ? isPrivateV6(address) : isPrivateV4(address);
130
+ /** Hostnames that name something internal by convention rather than by address. */
131
+ const INTERNAL_NAMES = new Set([
132
+ "localhost",
133
+ "ip6-localhost",
134
+ "ip6-loopback",
135
+ // The metadata service on GCP, Azure and Alibaba respectively. Each resolves to a link-local address, so the
136
+ // address check catches them too — named here so a refusal says *what* was refused.
137
+ "metadata.google.internal",
138
+ "metadata.azure.com",
139
+ "metadata",
140
+ ]);
141
+ /**
142
+ * Refuses a URL on its face, before any resolution.
143
+ *
144
+ * Scheme and shape only. A hostname that merely *looks* fine still has to survive resolution, which is the
145
+ * next function — this one exists so an obviously bad URL costs no DNS query and gives a specific message.
146
+ */
147
+ export const refuseUrl = (url) => {
148
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
149
+ return `${url.protocol}// is not a scheme this can fetch. Only http and https are supported — a file:, ftp: or data: URL is refused.`;
150
+ }
151
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
152
+ if (host === "")
153
+ return "That URL has no host.";
154
+ if (INTERNAL_NAMES.has(host) || host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".localhost")) {
155
+ return `${host} names something on the internal network, which this tool does not fetch.`;
156
+ }
157
+ /**
158
+ * An address literal is judged immediately, and a *hostname* is not judged here at all.
159
+ *
160
+ * A hostname cannot be classified without resolving it — that is vector 2 — so the temptation is to apply a
161
+ * heuristic. There isn't a sound one, and a heuristic here would be the check that makes the real check feel
162
+ * redundant.
163
+ */
164
+ if (/^[\d.]+$/.test(host) || host.includes(":")) {
165
+ if (isPrivateAddress(host)) {
166
+ return `${host} is a private, loopback or link-local address, which this tool does not fetch.`;
167
+ }
168
+ // A dotted-quad that is not four valid octets is either malformed or an obfuscated form like `0177.0.0.1`
169
+ // or `2130706433`, both of which resolve to loopback in some stacks. Neither is a URL anyone means.
170
+ if (!host.includes(":") && v4Parts(host) === null) {
171
+ return `${host} is not a valid IPv4 address. Octal, hexadecimal and integer forms of an address are refused.`;
172
+ }
173
+ }
174
+ return null;
175
+ };
176
+ export const systemResolve = (hostname) => new Promise((resolve, reject) => {
177
+ dnsLookup(hostname, { all: true, verbatim: true }, (error, addresses) => {
178
+ if (error)
179
+ reject(error);
180
+ else
181
+ resolve(addresses.map((entry) => entry.address));
182
+ });
183
+ });
184
+ export class BlockedError extends Error {
185
+ }
186
+ /**
187
+ * Resolves a host and refuses it if **any** address is private — then returns the addresses to connect to.
188
+ *
189
+ * *Any*, not *all*. A name with two `A` records, one public and one link-local, is a rebinding attack with the
190
+ * work done up front: connecting to whichever the resolver happened to order first is a coin flip, and a check
191
+ * that passes on a coin flip has not checked anything.
192
+ */
193
+ export const resolvePublicly = async (hostname, resolve) => {
194
+ let addresses;
195
+ try {
196
+ addresses = await resolve(hostname);
197
+ }
198
+ catch {
199
+ throw new BlockedError(`${hostname} could not be resolved.`);
200
+ }
201
+ if (addresses.length === 0)
202
+ throw new BlockedError(`${hostname} resolved to no addresses.`);
203
+ const blocked = addresses.filter((address) => isPrivateAddress(address));
204
+ if (blocked.length > 0) {
205
+ throw new BlockedError(`${hostname} resolves to ${blocked.join(", ")}, which is a private, loopback or link-local address. ` +
206
+ "A public hostname pointing at internal network space is how a fetch tool is turned into a way to read " +
207
+ "cloud metadata, so this is refused rather than followed.");
208
+ }
209
+ return addresses;
210
+ };
211
+ const DEFAULT_MAX_BYTES = 2_000_000;
212
+ const DEFAULT_TIMEOUT_MS = 15_000;
213
+ const DEFAULT_MAX_REDIRECTS = 5;
214
+ /**
215
+ * The real transport: connects to a validated address with `lookup` pinned.
216
+ *
217
+ * The `lookup` callback is what makes this sound. Node calls it at connect time instead of resolving the name,
218
+ * so the address that was checked is the address that is used — there is no second resolution between the
219
+ * check and the socket. The hostname still goes out in SNI and in the `Host` header, so the certificate is
220
+ * verified against the name the caller asked for, not against the IP.
221
+ */
222
+ export const nodeTransport = ({ url, addresses, headers, timeoutMs, maxBytes }) => new Promise((resolve, reject) => {
223
+ const pinned = addresses[0];
224
+ const family = pinned.includes(":") ? 6 : 4;
225
+ const options = {
226
+ method: "GET",
227
+ headers,
228
+ // Pinned. Called instead of a DNS lookup, with the address already validated above.
229
+ lookup: (_hostname, _options, callback) => {
230
+ callback(null, pinned, family);
231
+ },
232
+ };
233
+ const send = url.protocol === "https:" ? httpsRequest : httpRequest;
234
+ const request = send(url, options, (response) => {
235
+ const chunks = [];
236
+ let size = 0;
237
+ let truncated = false;
238
+ response.on("data", (chunk) => {
239
+ if (truncated)
240
+ return;
241
+ size += chunk.length;
242
+ if (size > maxBytes) {
243
+ // Bounded *while streaming*, not after: a byte cap enforced on a body already in memory is not a cap.
244
+ chunks.push(chunk.subarray(0, chunk.length - (size - maxBytes)));
245
+ truncated = true;
246
+ response.destroy();
247
+ return;
248
+ }
249
+ chunks.push(chunk);
250
+ });
251
+ const finish = () => {
252
+ const headerRecord = {};
253
+ for (const [name, value] of Object.entries(response.headers)) {
254
+ if (typeof value === "string")
255
+ headerRecord[name.toLowerCase()] = value;
256
+ else if (Array.isArray(value))
257
+ headerRecord[name.toLowerCase()] = value.join(", ");
258
+ }
259
+ resolve({
260
+ status: response.statusCode ?? 0,
261
+ headers: headerRecord,
262
+ body: Buffer.concat(chunks).toString("utf8"),
263
+ truncated,
264
+ });
265
+ };
266
+ response.on("end", finish);
267
+ response.on("close", () => {
268
+ if (truncated)
269
+ finish();
270
+ });
271
+ response.on("error", reject);
272
+ });
273
+ request.setTimeout(timeoutMs, () => {
274
+ request.destroy(new Error(`No response within ${timeoutMs}ms.`));
275
+ });
276
+ request.on("error", reject);
277
+ request.end();
278
+ });
279
+ /**
280
+ * Fetches a URL with every vector closed, following redirects **by re-running the whole check on each hop**.
281
+ *
282
+ * The shared `createHttpClient` refuses redirects outright, which is the right answer for a vendor API and the
283
+ * wrong one here: a great many real pages are one redirect away from their canonical URL, and a scraper that
284
+ * cannot follow `http → https` or `example.com → www.example.com` is a scraper that fails on ordinary input.
285
+ *
286
+ * So redirects are followed, and the price is that each hop is a fresh, untrusted URL. It gets `refuseUrl` and
287
+ * `resolvePublicly` exactly as the first one did. A chain that starts public and ends at `169.254.169.254` is
288
+ * refused at the hop that turns private, which is the only place it can be caught.
289
+ */
290
+ export const safeFetch = async (target, options = {}) => {
291
+ const resolve = options.resolve ?? systemResolve;
292
+ const transport = options.transport ?? nodeTransport;
293
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
294
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
295
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
296
+ const chain = [];
297
+ let current;
298
+ try {
299
+ current = new URL(target);
300
+ }
301
+ catch {
302
+ throw new BlockedError(`"${target}" is not a URL.`);
303
+ }
304
+ for (let hop = 0; hop <= maxRedirects; hop += 1) {
305
+ const refusal = refuseUrl(current);
306
+ if (refusal !== null)
307
+ throw new BlockedError(refusal);
308
+ const addresses = await resolvePublicly(current.hostname, resolve);
309
+ chain.push(current.toString());
310
+ const response = await transport({
311
+ url: current,
312
+ addresses,
313
+ headers: {
314
+ // A real, honest identifier. Several sites answer a missing User-Agent with a 429 that is not a rate
315
+ // limit, and misrepresenting the client is the bot-detection evasion this package declines to do.
316
+ "user-agent": options.headers?.["user-agent"] ?? DEFAULT_USER_AGENT,
317
+ accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
318
+ ...options.headers,
319
+ },
320
+ timeoutMs,
321
+ maxBytes,
322
+ });
323
+ const location = response.headers.location;
324
+ if (response.status >= 300 && response.status < 400 && location !== undefined && location !== "") {
325
+ if (hop === maxRedirects) {
326
+ throw new BlockedError(`That URL redirected more than ${maxRedirects} times, so the chain was abandoned.`);
327
+ }
328
+ let next;
329
+ try {
330
+ next = new URL(location, current);
331
+ }
332
+ catch {
333
+ throw new BlockedError(`That URL redirected to "${location}", which is not a URL.`);
334
+ }
335
+ current = next;
336
+ continue;
337
+ }
338
+ return {
339
+ url: current.toString(),
340
+ status: response.status,
341
+ headers: response.headers,
342
+ body: response.body,
343
+ truncated: response.truncated,
344
+ chain,
345
+ };
346
+ }
347
+ throw new BlockedError(`That URL redirected more than ${maxRedirects} times, so the chain was abandoned.`);
348
+ };
349
+ //# sourceMappingURL=ssrf.js.map
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The transport every sibling toolkit needs, once — REQ-052 (#224), task #225.
3
+ *
4
+ * `tools-github` grew one of these in #223 and it turned out to be four-fifths vendor-neutral: resolve the
5
+ * credential *per call*, pin the auth header to one validated host, parse JSON with an empty body tolerated,
6
+ * and map a failure onto the platform's closed error union. Only the last part is really vendor-specific, and
7
+ * only in its wording.
8
+ *
9
+ * Writing it twice more for Jira and Confluence — with nineteen toolkits after them — would mean copying four
10
+ * bugs along with it. #223 found two of them the hard way:
11
+ *
12
+ * - `JSON.parse("")` on a `204`. Two endpoints returned empty bodies on success and both tools failed outright
13
+ * while reporting a parse error about a correct response.
14
+ * - A plain-text body handled by catching the parse failure, which discarded the text and returned a
15
+ * placeholder. The tool typechecked and was entirely non-functional.
16
+ *
17
+ * Both are fixed here, so a toolkit gets them right by not writing them.
18
+ *
19
+ * **What stays with the vendor**: the failure vocabulary (`classify`), the base URL, and the fixed headers.
20
+ * A vendor that answers `200` with an error envelope — Slack's `ok: false`, GraphQL's `errors` — reads that
21
+ * envelope in its own module, because only it knows the shape.
22
+ */
23
+ import type { ExecutionContext } from "../core/context.js";
24
+ import { type PlatformError } from "../core/errors.js";
25
+ import type { CredentialRef, CredentialResolver } from "../tools/credentials.js";
26
+ import { type HttpOutcome } from "./http.js";
27
+ /** What a vendor's classifier is handed: the failed outcome, already narrowed. */
28
+ export type VendorFailure = Extract<HttpOutcome, {
29
+ ok: false;
30
+ }>;
31
+ /**
32
+ * How this vendor names its failures.
33
+ *
34
+ * Returning `undefined` accepts the default, which is deliberately conservative: a transport failure is
35
+ * `provider_unavailable` and retryable, `401`/`403` is `unauthorized` and not, and everything else is
36
+ * `provider_error` and not. A vendor overrides where its own signalling differs — GitHub reports a rate limit
37
+ * as `403` with a header, which no default could guess.
38
+ */
39
+ /**
40
+ * `PlatformError["code"]`, not a hand-picked subset.
41
+ *
42
+ * The first version listed four codes — the ones the default classification uses — and Jira needed a fifth the
43
+ * same afternoon: `409`/`412` is a `conflict`, which is a real platform code and was not on the list. Naming a
44
+ * subset means guessing which codes vendors will need, and the platform union is already the right constraint.
45
+ */
46
+ /**
47
+ * `retryAfterMs` is part of the return type, and its absence was a live bug.
48
+ *
49
+ * The default 429 arm below has always *set* it — inside an object typed as `Pick<…, "code"|"message"|"retryable">`,
50
+ * where a spread makes an excess property legal and unreadable. So the field was written, typechecked, and could
51
+ * not be read by the one line that needed it. See the propagation note in `request`.
52
+ */
53
+ export type VendorClassifier = (failure: VendorFailure) => Pick<PlatformError, "code" | "message" | "retryable" | "retryAfterMs"> | undefined;
54
+ export type VendorTransportConfig = {
55
+ /** Resolved per call, by the host. A toolkit must never read the environment. */
56
+ readonly credentialRef: CredentialRef;
57
+ readonly resolver: CredentialResolver;
58
+ /** No trailing slash needed; one is stripped. */
59
+ readonly baseUrl: string;
60
+ /** Sent on every request, alongside the resolved credential header. */
61
+ readonly headers?: Readonly<Record<string, string>>;
62
+ /** The vendor's own name for what went wrong. */
63
+ readonly classify?: VendorClassifier;
64
+ /** Injected so tests need no network. */
65
+ readonly fetchImpl?: typeof fetch;
66
+ /** Named in every error message, so a failure says which integration produced it. */
67
+ readonly vendor: string;
68
+ };
69
+ export type VendorTransport = {
70
+ /** Parsed JSON, or `undefined` when the vendor answered with no body — a `204` is a success. */
71
+ readonly json: (context: ExecutionContext, path: string, init?: {
72
+ readonly method?: string;
73
+ readonly body?: unknown;
74
+ }) => Promise<unknown>;
75
+ /** The body verbatim, for the endpoints that do not answer in JSON. */
76
+ readonly text: (context: ExecutionContext, path: string, init?: {
77
+ readonly method?: string;
78
+ readonly body?: unknown;
79
+ /**
80
+ * Sent **as-is**, with `contentType`, instead of being JSON-encoded.
81
+ *
82
+ * For a media endpoint: Drive's `uploadType=media` wants the file's bytes, and `JSON.stringify` on a
83
+ * string produces a quoted string — a file whose contents are a JSON literal, uploaded successfully.
84
+ * Nothing errors, which is why this is a parameter rather than a caller's `JSON.parse` dance.
85
+ */
86
+ readonly rawBody?: string;
87
+ readonly contentType?: string;
88
+ }) => Promise<string>;
89
+ };
90
+ export declare const createVendorTransport: (config: VendorTransportConfig) => VendorTransport;
91
+ //# sourceMappingURL=vendor.d.ts.map
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The transport every sibling toolkit needs, once — REQ-052 (#224), task #225.
3
+ *
4
+ * `tools-github` grew one of these in #223 and it turned out to be four-fifths vendor-neutral: resolve the
5
+ * credential *per call*, pin the auth header to one validated host, parse JSON with an empty body tolerated,
6
+ * and map a failure onto the platform's closed error union. Only the last part is really vendor-specific, and
7
+ * only in its wording.
8
+ *
9
+ * Writing it twice more for Jira and Confluence — with nineteen toolkits after them — would mean copying four
10
+ * bugs along with it. #223 found two of them the hard way:
11
+ *
12
+ * - `JSON.parse("")` on a `204`. Two endpoints returned empty bodies on success and both tools failed outright
13
+ * while reporting a parse error about a correct response.
14
+ * - A plain-text body handled by catching the parse failure, which discarded the text and returned a
15
+ * placeholder. The tool typechecked and was entirely non-functional.
16
+ *
17
+ * Both are fixed here, so a toolkit gets them right by not writing them.
18
+ *
19
+ * **What stays with the vendor**: the failure vocabulary (`classify`), the base URL, and the fixed headers.
20
+ * A vendor that answers `200` with an error envelope — Slack's `ok: false`, GraphQL's `errors` — reads that
21
+ * envelope in its own module, because only it knows the shape.
22
+ */
23
+ import { AgentPlatformError } from "../core/errors.js";
24
+ import { credentialHeader } from "../tools/credentials.js";
25
+ import { createHttpClient } from "./http.js";
26
+ /**
27
+ * The default classification, and why each arm is what it is.
28
+ *
29
+ * A model reads these to decide whether trying again is sensible, so the distinction that matters is
30
+ * *retryable*, not how precisely the words describe the HTTP status. `unauthorized` on a `403` is the important
31
+ * one: told "forbidden", a model retries with different arguments, which is never the fix for a missing scope.
32
+ */
33
+ const defaultClassification = (failure, vendor) => {
34
+ const transport = failure.kind === "timeout" || failure.kind === "unreachable";
35
+ if (transport) {
36
+ return {
37
+ code: "provider_unavailable",
38
+ message: `${vendor} request failed (${failure.kind}): ${failure.reason}`,
39
+ retryable: true,
40
+ };
41
+ }
42
+ if (failure.status === 429) {
43
+ /**
44
+ * `Retry-After` is honoured when the vendor sent one — the default backoff is a guess, and a vendor that
45
+ * told you the number has removed the need to guess. Meta, X and Reddit all send it.
46
+ */
47
+ return {
48
+ code: "rate_limited",
49
+ message: `${vendor} rate limit reached: ${failure.reason}` +
50
+ (failure.retryAfterMs === undefined ? "" : ` Retry after ${Math.ceil(failure.retryAfterMs / 1000)}s.`),
51
+ retryable: true,
52
+ ...(failure.retryAfterMs === undefined ? {} : { retryAfterMs: failure.retryAfterMs }),
53
+ };
54
+ }
55
+ if (failure.status === 401 || failure.status === 403) {
56
+ return {
57
+ code: "unauthorized",
58
+ message: `${vendor} refused the credential (${failure.status}): ${failure.reason}. The credential may lack the permission this tool needs.`,
59
+ retryable: false,
60
+ };
61
+ }
62
+ return { code: "provider_error", message: `${vendor} request failed (${failure.kind}): ${failure.reason}`, retryable: false };
63
+ };
64
+ export const createVendorTransport = (config) => {
65
+ const base = config.baseUrl.replace(/\/$/, "");
66
+ const host = new URL(base).host;
67
+ /**
68
+ * One request, with the credential resolved now rather than at construction.
69
+ *
70
+ * Per call so a rotated token takes effect without a restart — a credential read once at startup is one that
71
+ * survives its own rotation, and the failure looks like the vendor rejecting a token that "has not changed".
72
+ *
73
+ * The header goes in through `headersFor`, which the runtime calls with the **validated** hostname only: a
74
+ * credential issued for one host cannot be sent to another by asking for a URL that merely mentions it.
75
+ */
76
+ const request = async (context, path, init = {}) => {
77
+ const credential = await config.resolver.resolve({ ref: config.credentialRef, context });
78
+ // One helper, so twenty toolkits do not each write their own base64 and get the padding wrong — #260.
79
+ const [headerName, headerValue] = credentialHeader(credential);
80
+ const client = createHttpClient({
81
+ ...(config.fetchImpl === undefined ? {} : { fetchImpl: config.fetchImpl }),
82
+ headersFor: (requested) => requested === host
83
+ ? {
84
+ [headerName.toLowerCase()]: headerValue,
85
+ ...(config.headers ?? {}),
86
+ // Overrides the vendor's default, which is JSON for every other call this transport makes.
87
+ ...(init.contentType === undefined ? {} : { "content-type": init.contentType }),
88
+ }
89
+ : undefined,
90
+ });
91
+ const outcome = await client.request({
92
+ url: `${base}${path}`,
93
+ ...(init.method === undefined ? {} : { method: init.method }),
94
+ ...(init.rawBody !== undefined
95
+ ? { body: init.rawBody }
96
+ : init.body === undefined
97
+ ? {}
98
+ : { body: JSON.stringify(init.body) }),
99
+ // Parsed here and never shown to the model verbatim, so the untrusted-content envelope would only corrupt
100
+ // the JSON. Anything rendered as prose keeps the default fence.
101
+ fence: false,
102
+ });
103
+ if (outcome.ok)
104
+ return outcome.body;
105
+ const failure = outcome;
106
+ const described = config.classify?.(failure) ?? defaultClassification(failure, config.vendor);
107
+ /**
108
+ * `Retry-After`, carried through — and it was being dropped.
109
+ *
110
+ * `HttpFailure` parses the header and `PlatformError` has a field for it, and this transport joined them by
111
+ * building an error that mentioned neither. Every toolkit on it therefore ignored a vendor that had said
112
+ * *exactly* how long to wait, and fell back to a generic backoff — which is both slower than necessary and,
113
+ * against a service that counts requests during the window, a way to stay throttled.
114
+ *
115
+ * A classifier may override it; otherwise the server's own number wins over any default, because the server
116
+ * is the only party that knows.
117
+ */
118
+ const retryAfterMs = described.retryAfterMs ?? failure.retryAfterMs;
119
+ /**
120
+ * `AgentPlatformError`, not a decorated `Error`.
121
+ *
122
+ * `toPlatformError` maps anything else to `{ code: "internal", retryable: false }` — so a rate limit thrown
123
+ * as `Object.assign(new Error(…), { retryable: true })` arrives at the model as permanently broken. The
124
+ * extra properties simply vanish and nothing warns you.
125
+ */
126
+ throw new AgentPlatformError(retryAfterMs === undefined ? described : { ...described, retryAfterMs });
127
+ };
128
+ return {
129
+ async json(context, path, init) {
130
+ const body = await request(context, path, init);
131
+ // **An empty body is a success.** A `204` from a delete, or a `201` from a fire-and-forget POST, has
132
+ // nothing to parse — and `JSON.parse("")` throws, which reported two correct responses as parse failures.
133
+ if (body.trim() === "")
134
+ return undefined;
135
+ try {
136
+ return JSON.parse(body);
137
+ }
138
+ catch {
139
+ throw new AgentPlatformError({
140
+ code: "provider_error",
141
+ message: `${config.vendor} returned a body that is not JSON`,
142
+ retryable: false,
143
+ });
144
+ }
145
+ },
146
+ text(context, path, init) {
147
+ // Not `json` with the parse failure caught: that discards the body, which is how a log-reading tool
148
+ // shipped returning a placeholder on every success.
149
+ return request(context, path, init ?? {});
150
+ },
151
+ };
152
+ };
153
+ //# sourceMappingURL=vendor.js.map