@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.
- package/README.md +1 -1
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +78 -1
- package/dist/agents/agent.js +118 -5
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +20 -0
- package/dist/agents/engine.js +177 -6
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/content-parts.d.ts +18 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +44 -2
- package/dist/core/events.js +31 -2
- package/dist/core/validation.js +19 -0
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/knowledge.d.ts +8 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +3 -1
- package/dist/graphql/resolvers.js +14 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +24 -0
- package/dist/knowledge/index.js +36 -1
- package/dist/knowledge/retrieval.d.ts +26 -1
- package/dist/knowledge/retrieval.js +77 -1
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +89 -0
- package/dist/models/streaming.js +179 -2
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +46 -0
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +5 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +27 -1
- package/dist/toolkit/index.js +24 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/tools/credentials.d.ts +265 -6
- package/dist/tools/credentials.js +318 -3
- package/dist/tools/define.d.ts +14 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/index.d.ts +8 -0
- package/dist/tools/library/index.js +7 -0
- package/dist/tools/registry.d.ts +33 -0
- package/dist/tools/registry.js +72 -2
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- 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
|