dsh-codex-community 0.0.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.
- package/CHANGELOG.md +90 -0
- package/CONTRIBUTING.en.md +54 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.en.md +98 -0
- package/README.md +98 -0
- package/SECURITY.md +41 -0
- package/SUPPORT.md +17 -0
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/codex-community.patch.yml +13 -0
- package/dist/client/index.js +1037 -0
- package/dist/host/index.mjs +98 -0
- package/dist/internal/authorization-bridge.mjs +662 -0
- package/dist/internal/authorization-commit-tracker.mjs +49 -0
- package/dist/internal/codex-authorization.mjs +202 -0
- package/dist/internal/codex-credential-store.mjs +164 -0
- package/dist/internal/codex-identifiers.mjs +4 -0
- package/dist/internal/codex-pi-provider.mjs +137 -0
- package/dist/internal/codex-provider-runtime.mjs +256 -0
- package/dist/internal/codex-route-adapter.mjs +133 -0
- package/dist/internal/codex-session-resources.mjs +64 -0
- package/dist/internal/failure-normalizer.mjs +456 -0
- package/dist/internal/image-policy.mjs +45 -0
- package/dist/internal/quota-observer.mjs +142 -0
- package/dist/internal/reliability.mjs +12 -0
- package/dist/internal/remote-image-input.mjs +801 -0
- package/dist/internal/session-preference-command.mjs +52 -0
- package/dist/internal/session-preferences.mjs +93 -0
- package/dist/internal/stream-resilience.mjs +273 -0
- package/docs/README.en.md +15 -0
- package/docs/README.md +15 -0
- package/docs/architecture.en.md +106 -0
- package/docs/architecture.md +106 -0
- package/docs/compatibility.en.md +53 -0
- package/docs/compatibility.md +53 -0
- package/docs/configuration.en.md +61 -0
- package/docs/configuration.md +61 -0
- package/docs/contribution-sources.en.md +35 -0
- package/docs/contribution-sources.md +35 -0
- package/docs/github-about.md +15 -0
- package/docs/releases/v0.0.1.acceptance.json +160 -0
- package/docs/releases/v0.0.1.md +174 -0
- package/docs/releasing.en.md +290 -0
- package/docs/releasing.md +290 -0
- package/docs/testing.en.md +85 -0
- package/docs/testing.md +85 -0
- package/docs/troubleshooting.en.md +47 -0
- package/docs/troubleshooting.md +47 -0
- package/package.json +144 -0
- package/types/client.d.ts +160 -0
- package/types/index.d.ts +22 -0
- package/types/reliability.d.ts +78 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from "node:dns/promises"
|
|
2
|
+
import { request as httpRequest } from "node:http"
|
|
3
|
+
import { request as httpsRequest } from "node:https"
|
|
4
|
+
import { isIP } from "node:net"
|
|
5
|
+
import { promisify } from "node:util"
|
|
6
|
+
import {
|
|
7
|
+
brotliDecompress,
|
|
8
|
+
gunzip,
|
|
9
|
+
inflate,
|
|
10
|
+
} from "node:zlib"
|
|
11
|
+
|
|
12
|
+
import { CODEX_ROUTE_ID } from "./codex-identifiers.mjs"
|
|
13
|
+
|
|
14
|
+
const gunzipAsync = promisify(gunzip)
|
|
15
|
+
const inflateAsync = promisify(inflate)
|
|
16
|
+
const brotliDecompressAsync = promisify(brotliDecompress)
|
|
17
|
+
|
|
18
|
+
export const REMOTE_IMAGE_MEDIA_TYPES = Object.freeze([
|
|
19
|
+
"image/png",
|
|
20
|
+
"image/jpeg",
|
|
21
|
+
"image/webp",
|
|
22
|
+
"image/gif",
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_REMOTE_IMAGE_POLICY = Object.freeze({
|
|
26
|
+
maxBytes: 20 * 1024 * 1024,
|
|
27
|
+
maxRedirects: 3,
|
|
28
|
+
timeoutMs: 15_000,
|
|
29
|
+
mediaTypes: REMOTE_IMAGE_MEDIA_TYPES,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
33
|
+
const MAX_LOCATION_LENGTH = 8_192
|
|
34
|
+
const MAX_URL_LENGTH = 8_192
|
|
35
|
+
const MAX_RESPONSE_CHUNKS = 16_384
|
|
36
|
+
const MAX_CONCURRENT_REMOTE_IMAGES = 2
|
|
37
|
+
const MAX_QUEUED_REMOTE_IMAGES = 32
|
|
38
|
+
|
|
39
|
+
export class RemoteImageInputError extends Error {
|
|
40
|
+
constructor(message, code, options) {
|
|
41
|
+
super(message, options)
|
|
42
|
+
this.name = "RemoteImageInputError"
|
|
43
|
+
this.code = code
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function positiveSafeInteger(value, name) {
|
|
48
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
49
|
+
throw new TypeError(`${name} must be a positive safe integer`)
|
|
50
|
+
}
|
|
51
|
+
return value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nonNegativeSafeInteger(value, name) {
|
|
55
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
56
|
+
throw new TypeError(`${name} must be a non-negative safe integer`)
|
|
57
|
+
}
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveMediaTypes(value) {
|
|
62
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
63
|
+
throw new TypeError("mediaTypes must be a non-empty array")
|
|
64
|
+
}
|
|
65
|
+
const unique = []
|
|
66
|
+
for (const mediaType of value) {
|
|
67
|
+
if (!REMOTE_IMAGE_MEDIA_TYPES.includes(mediaType)) {
|
|
68
|
+
throw new TypeError(`unsupported remote image media type: ${String(mediaType)}`)
|
|
69
|
+
}
|
|
70
|
+
if (!unique.includes(mediaType)) unique.push(mediaType)
|
|
71
|
+
}
|
|
72
|
+
return Object.freeze(unique)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveRemoteImagePolicy(input = {}) {
|
|
76
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
77
|
+
throw new TypeError("remote image policy must be an object")
|
|
78
|
+
}
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
maxBytes: positiveSafeInteger(input.maxBytes ?? DEFAULT_REMOTE_IMAGE_POLICY.maxBytes, "maxBytes"),
|
|
81
|
+
maxRedirects: nonNegativeSafeInteger(
|
|
82
|
+
input.maxRedirects ?? DEFAULT_REMOTE_IMAGE_POLICY.maxRedirects,
|
|
83
|
+
"maxRedirects",
|
|
84
|
+
),
|
|
85
|
+
timeoutMs: positiveSafeInteger(input.timeoutMs ?? DEFAULT_REMOTE_IMAGE_POLICY.timeoutMs, "timeoutMs"),
|
|
86
|
+
mediaTypes: resolveMediaTypes(input.mediaTypes ?? DEFAULT_REMOTE_IMAGE_POLICY.mediaTypes),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ipv4Value(address) {
|
|
91
|
+
const parts = address.split(".")
|
|
92
|
+
if (parts.length !== 4) return undefined
|
|
93
|
+
let value = 0n
|
|
94
|
+
for (const part of parts) {
|
|
95
|
+
if (!/^\d{1,3}$/u.test(part)) return undefined
|
|
96
|
+
const octet = Number(part)
|
|
97
|
+
if (octet > 255) return undefined
|
|
98
|
+
value = (value << 8n) | BigInt(octet)
|
|
99
|
+
}
|
|
100
|
+
return value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ipv6Value(rawAddress) {
|
|
104
|
+
let address = rawAddress.toLowerCase()
|
|
105
|
+
if (address.startsWith("[") && address.endsWith("]")) address = address.slice(1, -1)
|
|
106
|
+
if (address.includes("%")) return undefined
|
|
107
|
+
|
|
108
|
+
const embeddedV4 = address.includes(".")
|
|
109
|
+
if (embeddedV4) {
|
|
110
|
+
const separator = address.lastIndexOf(":")
|
|
111
|
+
if (separator < 0) return undefined
|
|
112
|
+
const v4 = ipv4Value(address.slice(separator + 1))
|
|
113
|
+
if (v4 === undefined) return undefined
|
|
114
|
+
const high = ((v4 >> 16n) & 0xffffn).toString(16)
|
|
115
|
+
const low = (v4 & 0xffffn).toString(16)
|
|
116
|
+
address = `${address.slice(0, separator)}:${high}:${low}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const halves = address.split("::")
|
|
120
|
+
if (halves.length > 2) return undefined
|
|
121
|
+
const left = halves[0] === "" ? [] : halves[0].split(":")
|
|
122
|
+
const right = halves.length === 1 || halves[1] === "" ? [] : halves[1].split(":")
|
|
123
|
+
if (left.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return undefined
|
|
124
|
+
if (right.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return undefined
|
|
125
|
+
|
|
126
|
+
const omitted = 8 - left.length - right.length
|
|
127
|
+
if ((halves.length === 1 && omitted !== 0) || (halves.length === 2 && omitted < 1)) return undefined
|
|
128
|
+
const parts = [...left, ...Array.from({ length: omitted }, () => "0"), ...right]
|
|
129
|
+
if (parts.length !== 8) return undefined
|
|
130
|
+
return parts.reduce((value, part) => (value << 16n) | BigInt(`0x${part}`), 0n)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function inCidr(value, network, prefix, bits) {
|
|
134
|
+
const shift = BigInt(bits - prefix)
|
|
135
|
+
return (value >> shift) === (network >> shift)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const IPV4_BLOCKS = [
|
|
139
|
+
["0.0.0.0", 8],
|
|
140
|
+
["10.0.0.0", 8],
|
|
141
|
+
["100.64.0.0", 10],
|
|
142
|
+
["127.0.0.0", 8],
|
|
143
|
+
["168.63.129.16", 32],
|
|
144
|
+
["169.254.0.0", 16],
|
|
145
|
+
["172.16.0.0", 12],
|
|
146
|
+
["192.0.0.0", 24],
|
|
147
|
+
["192.0.2.0", 24],
|
|
148
|
+
["192.88.99.0", 24],
|
|
149
|
+
["192.168.0.0", 16],
|
|
150
|
+
["198.18.0.0", 15],
|
|
151
|
+
["198.51.100.0", 24],
|
|
152
|
+
["203.0.113.0", 24],
|
|
153
|
+
["224.0.0.0", 4],
|
|
154
|
+
["240.0.0.0", 4],
|
|
155
|
+
].map(([address, prefix]) => [ipv4Value(address), prefix])
|
|
156
|
+
|
|
157
|
+
const IPV6_GLOBAL = [ipv6Value("2000::"), 3]
|
|
158
|
+
const IPV6_BLOCKS = [
|
|
159
|
+
["2001::", 23],
|
|
160
|
+
["2001:db8::", 32],
|
|
161
|
+
["2002::", 16],
|
|
162
|
+
["3fff::", 20],
|
|
163
|
+
].map(([address, prefix]) => [ipv6Value(address), prefix])
|
|
164
|
+
|
|
165
|
+
/** Return true only for an ordinary globally routable unicast address. */
|
|
166
|
+
export function isPublicIpAddress(address) {
|
|
167
|
+
const family = isIP(address)
|
|
168
|
+
if (family === 4) {
|
|
169
|
+
const value = ipv4Value(address)
|
|
170
|
+
return value !== undefined
|
|
171
|
+
&& !IPV4_BLOCKS.some(([network, prefix]) => inCidr(value, network, prefix, 32))
|
|
172
|
+
}
|
|
173
|
+
if (family === 6) {
|
|
174
|
+
const value = ipv6Value(address)
|
|
175
|
+
if (value === undefined) return false
|
|
176
|
+
if (!inCidr(value, IPV6_GLOBAL[0], IPV6_GLOBAL[1], 128)) return false
|
|
177
|
+
return !IPV6_BLOCKS.some(([network, prefix]) => inCidr(value, network, prefix, 128))
|
|
178
|
+
}
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function unbracket(hostname) {
|
|
183
|
+
return hostname.startsWith("[") && hostname.endsWith("]")
|
|
184
|
+
? hostname.slice(1, -1)
|
|
185
|
+
: hostname
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizedHostname(hostname) {
|
|
189
|
+
const host = unbracket(hostname).toLowerCase()
|
|
190
|
+
return host.endsWith(".") ? host.slice(0, -1) : host
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const FORBIDDEN_HOST_SUFFIXES = [
|
|
194
|
+
".home.arpa",
|
|
195
|
+
".internal",
|
|
196
|
+
".invalid",
|
|
197
|
+
".local",
|
|
198
|
+
".localhost",
|
|
199
|
+
".test",
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
function hasForbiddenHostSuffix(hostname) {
|
|
203
|
+
return FORBIDDEN_HOST_SUFFIXES.some((suffix) => (
|
|
204
|
+
hostname === suffix.slice(1) || hostname.endsWith(suffix)
|
|
205
|
+
))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function assertRemoteUrl(input, base) {
|
|
209
|
+
let url
|
|
210
|
+
try {
|
|
211
|
+
url = base === undefined ? new URL(input) : new URL(input, base)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw new RemoteImageInputError("remote image URL is invalid", "INVALID_URL", { cause: error })
|
|
214
|
+
}
|
|
215
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
216
|
+
throw new RemoteImageInputError("remote image URL must use http or https", "UNSUPPORTED_PROTOCOL")
|
|
217
|
+
}
|
|
218
|
+
if (url.username !== "" || url.password !== "") {
|
|
219
|
+
throw new RemoteImageInputError("remote image URL must not contain user information", "URL_CREDENTIALS")
|
|
220
|
+
}
|
|
221
|
+
if (url.hostname === "") {
|
|
222
|
+
throw new RemoteImageInputError("remote image URL must contain a host", "INVALID_URL")
|
|
223
|
+
}
|
|
224
|
+
if (url.href.length > MAX_URL_LENGTH) {
|
|
225
|
+
throw new RemoteImageInputError("remote image URL is too long", "INVALID_URL")
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const hostname = normalizedHostname(url.hostname)
|
|
229
|
+
if (hostname === "localhost" || hasForbiddenHostSuffix(hostname)) {
|
|
230
|
+
throw new RemoteImageInputError("remote image URL targets a non-public host", "UNSAFE_ADDRESS")
|
|
231
|
+
}
|
|
232
|
+
if (isIP(hostname) === 0 && !hostname.includes(".")) {
|
|
233
|
+
throw new RemoteImageInputError("remote image URL host must be fully qualified", "UNSAFE_ADDRESS")
|
|
234
|
+
}
|
|
235
|
+
return url
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeLookupResult(result) {
|
|
239
|
+
const entries = Array.isArray(result) ? result : [result]
|
|
240
|
+
const addresses = []
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
const address = typeof entry === "string" ? entry : entry?.address
|
|
243
|
+
const family = typeof entry === "object" && entry !== null ? entry.family : isIP(address)
|
|
244
|
+
const detected = typeof address === "string" ? isIP(address) : 0
|
|
245
|
+
if (detected === 0 || (family !== 4 && family !== 6) || detected !== Number(family)) {
|
|
246
|
+
throw new RemoteImageInputError("DNS returned an invalid address", "DNS_FAILED")
|
|
247
|
+
}
|
|
248
|
+
if (!isPublicIpAddress(address)) {
|
|
249
|
+
throw new RemoteImageInputError("remote image URL resolved to a non-public address", "UNSAFE_ADDRESS")
|
|
250
|
+
}
|
|
251
|
+
const key = `${detected}:${address}`
|
|
252
|
+
if (!addresses.some((candidate) => candidate.key === key)) {
|
|
253
|
+
addresses.push({ key, address, family: detected })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (addresses.length === 0) {
|
|
257
|
+
throw new RemoteImageInputError("remote image host did not resolve", "DNS_FAILED")
|
|
258
|
+
}
|
|
259
|
+
return addresses.map(({ address, family }) => Object.freeze({ address, family }))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function awaitWithSignal(value, signal) {
|
|
263
|
+
if (signal.aborted) return Promise.reject(abortReason(signal))
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
265
|
+
const onAbort = () => {
|
|
266
|
+
signal.removeEventListener("abort", onAbort)
|
|
267
|
+
reject(abortReason(signal))
|
|
268
|
+
}
|
|
269
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
270
|
+
Promise.resolve(value).then(resolve, reject).finally(() => {
|
|
271
|
+
signal.removeEventListener("abort", onAbort)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function resolveAddresses(url, lookup, signal) {
|
|
277
|
+
const hostname = normalizedHostname(url.hostname)
|
|
278
|
+
if (isIP(hostname) !== 0) return normalizeLookupResult([{ address: hostname, family: isIP(hostname) }])
|
|
279
|
+
let result
|
|
280
|
+
try {
|
|
281
|
+
result = await awaitWithSignal(
|
|
282
|
+
Promise.resolve().then(() => lookup(hostname, { all: true, verbatim: true })),
|
|
283
|
+
signal,
|
|
284
|
+
)
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof RemoteImageInputError) throw error
|
|
287
|
+
throw new RemoteImageInputError("remote image host lookup failed", "DNS_FAILED", { cause: error })
|
|
288
|
+
}
|
|
289
|
+
return normalizeLookupResult(result)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function pinnedLookup(hostname, addresses) {
|
|
293
|
+
const expected = normalizedHostname(hostname)
|
|
294
|
+
return (requestedHostname, options, callback) => {
|
|
295
|
+
const done = typeof options === "function" ? options : callback
|
|
296
|
+
const lookupOptions = typeof options === "object" && options !== null ? options : {}
|
|
297
|
+
if (typeof done !== "function") throw new TypeError("lookup callback is required")
|
|
298
|
+
if (normalizedHostname(requestedHostname) !== expected) {
|
|
299
|
+
done(new RemoteImageInputError("request attempted an unpinned DNS lookup", "DNS_REBINDING_GUARD"))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const requestedFamily = Number(lookupOptions.family) || 0
|
|
303
|
+
const candidates = requestedFamily === 0
|
|
304
|
+
? addresses
|
|
305
|
+
: addresses.filter((candidate) => candidate.family === requestedFamily)
|
|
306
|
+
if (candidates.length === 0) {
|
|
307
|
+
done(new RemoteImageInputError("no pinned address matches the requested family", "DNS_REBINDING_GUARD"))
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (lookupOptions.all === true) {
|
|
311
|
+
done(null, candidates.map(({ address, family }) => ({ address, family })))
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
done(null, candidates[0].address, candidates[0].family)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function openNodeResponse(url, options) {
|
|
319
|
+
const request = url.protocol === "https:" ? httpsRequest : httpRequest
|
|
320
|
+
return new Promise((resolve, reject) => {
|
|
321
|
+
const outgoing = request(url, {
|
|
322
|
+
agent: false,
|
|
323
|
+
headers: options.headers,
|
|
324
|
+
lookup: options.lookup,
|
|
325
|
+
maxHeaderSize: 16 * 1024,
|
|
326
|
+
method: "GET",
|
|
327
|
+
signal: options.signal,
|
|
328
|
+
}, resolve)
|
|
329
|
+
outgoing.once("error", reject)
|
|
330
|
+
outgoing.end()
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function headerValue(headers, name) {
|
|
335
|
+
const value = headers?.[name]
|
|
336
|
+
if (Array.isArray(value)) return value.join(",")
|
|
337
|
+
return typeof value === "string" ? value : undefined
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function contentLength(response) {
|
|
341
|
+
const header = headerValue(response.headers, "content-length")
|
|
342
|
+
if (header === undefined) return undefined
|
|
343
|
+
if (!/^\d+$/u.test(header)) {
|
|
344
|
+
throw new RemoteImageInputError("remote image response has an invalid content length", "INVALID_RESPONSE")
|
|
345
|
+
}
|
|
346
|
+
const value = Number(header)
|
|
347
|
+
if (!Number.isSafeInteger(value)) {
|
|
348
|
+
throw new RemoteImageInputError("remote image response has an invalid content length", "INVALID_RESPONSE")
|
|
349
|
+
}
|
|
350
|
+
return value
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function readBounded(response, maxBytes, signal) {
|
|
354
|
+
const declared = contentLength(response)
|
|
355
|
+
if (declared !== undefined && declared > maxBytes) {
|
|
356
|
+
response.destroy?.()
|
|
357
|
+
throw new RemoteImageInputError("remote image response exceeds the byte limit", "RESPONSE_TOO_LARGE")
|
|
358
|
+
}
|
|
359
|
+
const chunks = []
|
|
360
|
+
let bytes = 0
|
|
361
|
+
for await (const value of response) {
|
|
362
|
+
if (signal.aborted) throw signal.reason
|
|
363
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value)
|
|
364
|
+
bytes += chunk.byteLength
|
|
365
|
+
if (bytes > maxBytes) {
|
|
366
|
+
response.destroy?.()
|
|
367
|
+
throw new RemoteImageInputError("remote image response exceeds the byte limit", "RESPONSE_TOO_LARGE")
|
|
368
|
+
}
|
|
369
|
+
if (chunks.length >= MAX_RESPONSE_CHUNKS) {
|
|
370
|
+
response.destroy?.()
|
|
371
|
+
throw new RemoteImageInputError("remote image response is excessively fragmented", "INVALID_RESPONSE")
|
|
372
|
+
}
|
|
373
|
+
chunks.push(chunk)
|
|
374
|
+
}
|
|
375
|
+
return Buffer.concat(chunks, bytes)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function decodeBody(data, response, maxBytes) {
|
|
379
|
+
const raw = headerValue(response.headers, "content-encoding")?.trim().toLowerCase()
|
|
380
|
+
const encoding = raw === undefined || raw === "" ? "identity" : raw
|
|
381
|
+
if (encoding.includes(",")) {
|
|
382
|
+
throw new RemoteImageInputError("stacked content encodings are not accepted", "UNSUPPORTED_CONTENT_ENCODING")
|
|
383
|
+
}
|
|
384
|
+
if (encoding === "identity") return data
|
|
385
|
+
|
|
386
|
+
let decode
|
|
387
|
+
if (encoding === "gzip" || encoding === "x-gzip") decode = gunzipAsync
|
|
388
|
+
else if (encoding === "deflate") decode = inflateAsync
|
|
389
|
+
else if (encoding === "br") decode = brotliDecompressAsync
|
|
390
|
+
else {
|
|
391
|
+
throw new RemoteImageInputError("remote image content encoding is not supported", "UNSUPPORTED_CONTENT_ENCODING")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const decoded = await decode(data, { maxOutputLength: maxBytes })
|
|
396
|
+
if (decoded.byteLength > maxBytes) {
|
|
397
|
+
throw new RemoteImageInputError("decompressed remote image exceeds the byte limit", "RESPONSE_TOO_LARGE")
|
|
398
|
+
}
|
|
399
|
+
return decoded
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (error instanceof RemoteImageInputError) throw error
|
|
402
|
+
if (error?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength|larger than/u.test(error?.message ?? "")) {
|
|
403
|
+
throw new RemoteImageInputError("decompressed remote image exceeds the byte limit", "RESPONSE_TOO_LARGE", {
|
|
404
|
+
cause: error,
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
throw new RemoteImageInputError("remote image content encoding is invalid", "INVALID_CONTENT_ENCODING", {
|
|
408
|
+
cause: error,
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function responseMediaType(response, allowed) {
|
|
414
|
+
const raw = headerValue(response.headers, "content-type")
|
|
415
|
+
const mediaType = raw?.split(";", 1)[0].trim().toLowerCase()
|
|
416
|
+
if (mediaType === undefined || !allowed.includes(mediaType)) {
|
|
417
|
+
throw new RemoteImageInputError("remote response is not an allowed image media type", "UNSUPPORTED_MEDIA_TYPE")
|
|
418
|
+
}
|
|
419
|
+
return mediaType
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function safeImageName(url, mediaType) {
|
|
423
|
+
const lastSegment = url.pathname.split("/").at(-1)
|
|
424
|
+
let decoded = lastSegment
|
|
425
|
+
try {
|
|
426
|
+
decoded = decodeURIComponent(lastSegment)
|
|
427
|
+
} catch {
|
|
428
|
+
// Keep the URL-encoded segment; it is display-only and never interpreted as a path.
|
|
429
|
+
}
|
|
430
|
+
const cleaned = decoded
|
|
431
|
+
?.replace(/[\u0000-\u001f\u007f/\\]/gu, "_")
|
|
432
|
+
.trim()
|
|
433
|
+
.slice(0, 120)
|
|
434
|
+
if (cleaned) return cleaned
|
|
435
|
+
const extension = mediaType === "image/jpeg" ? "jpg" : mediaType.slice("image/".length)
|
|
436
|
+
return `remote-image.${extension}`
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function deadline(parentSignal, timeoutMs) {
|
|
440
|
+
const controller = new AbortController()
|
|
441
|
+
let timer
|
|
442
|
+
const abortFromParent = () => {
|
|
443
|
+
controller.abort(parentSignal.reason ?? new RemoteImageInputError("remote image request was aborted", "ABORTED"))
|
|
444
|
+
}
|
|
445
|
+
if (parentSignal?.aborted) abortFromParent()
|
|
446
|
+
else parentSignal?.addEventListener("abort", abortFromParent, { once: true })
|
|
447
|
+
if (!controller.signal.aborted) {
|
|
448
|
+
timer = setTimeout(() => {
|
|
449
|
+
controller.abort(new RemoteImageInputError("remote image request timed out", "TIMEOUT"))
|
|
450
|
+
}, timeoutMs)
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
signal: controller.signal,
|
|
454
|
+
dispose() {
|
|
455
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
456
|
+
parentSignal?.removeEventListener("abort", abortFromParent)
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function abortReason(signal) {
|
|
462
|
+
if (signal.reason instanceof Error) return signal.reason
|
|
463
|
+
return new RemoteImageInputError("remote image request was aborted", "ABORTED")
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function linkedAbortSignal(...sources) {
|
|
467
|
+
const controller = new AbortController()
|
|
468
|
+
const listeners = []
|
|
469
|
+
const abortFrom = (source) => {
|
|
470
|
+
if (controller.signal.aborted) return
|
|
471
|
+
controller.abort(source.reason ?? new RemoteImageInputError("remote image request was aborted", "ABORTED"))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (const source of sources) {
|
|
475
|
+
if (source === undefined) continue
|
|
476
|
+
if (source.aborted) {
|
|
477
|
+
abortFrom(source)
|
|
478
|
+
break
|
|
479
|
+
}
|
|
480
|
+
const listener = () => abortFrom(source)
|
|
481
|
+
source.addEventListener("abort", listener, { once: true })
|
|
482
|
+
listeners.push({ source, listener })
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
signal: controller.signal,
|
|
487
|
+
dispose() {
|
|
488
|
+
for (const { source, listener } of listeners) {
|
|
489
|
+
source.removeEventListener("abort", listener)
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
class RemoteImageWorkLimiter {
|
|
496
|
+
#active = 0
|
|
497
|
+
#queue = []
|
|
498
|
+
#disposedReason
|
|
499
|
+
#idleWaiters = []
|
|
500
|
+
|
|
501
|
+
run(signal, task) {
|
|
502
|
+
if (this.#disposedReason !== undefined) return Promise.reject(this.#disposedReason)
|
|
503
|
+
if (signal?.aborted === true) return Promise.reject(abortReason(signal))
|
|
504
|
+
if (this.#active >= MAX_CONCURRENT_REMOTE_IMAGES && this.#queue.length >= MAX_QUEUED_REMOTE_IMAGES) {
|
|
505
|
+
return Promise.reject(new RemoteImageInputError(
|
|
506
|
+
"too many remote image requests are pending",
|
|
507
|
+
"TOO_MANY_REQUESTS",
|
|
508
|
+
))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return new Promise((resolve, reject) => {
|
|
512
|
+
const entry = {
|
|
513
|
+
signal,
|
|
514
|
+
task,
|
|
515
|
+
resolve,
|
|
516
|
+
reject,
|
|
517
|
+
started: false,
|
|
518
|
+
onAbort: undefined,
|
|
519
|
+
}
|
|
520
|
+
entry.onAbort = () => {
|
|
521
|
+
if (entry.started) return
|
|
522
|
+
const index = this.#queue.indexOf(entry)
|
|
523
|
+
if (index < 0) return
|
|
524
|
+
this.#queue.splice(index, 1)
|
|
525
|
+
signal.removeEventListener("abort", entry.onAbort)
|
|
526
|
+
reject(abortReason(signal))
|
|
527
|
+
}
|
|
528
|
+
signal?.addEventListener("abort", entry.onAbort, { once: true })
|
|
529
|
+
this.#queue.push(entry)
|
|
530
|
+
this.#drain()
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
dispose(reason = new RemoteImageInputError("remote image middleware was disposed", "ABORTED")) {
|
|
535
|
+
if (this.#disposedReason === undefined) {
|
|
536
|
+
this.#disposedReason = reason
|
|
537
|
+
for (const entry of this.#queue.splice(0)) {
|
|
538
|
+
entry.signal?.removeEventListener("abort", entry.onAbort)
|
|
539
|
+
entry.reject(reason)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (this.#active === 0) return Promise.resolve()
|
|
543
|
+
return new Promise((resolve) => { this.#idleWaiters.push(resolve) })
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
#drain() {
|
|
547
|
+
if (this.#disposedReason !== undefined) return
|
|
548
|
+
while (this.#active < MAX_CONCURRENT_REMOTE_IMAGES && this.#queue.length > 0) {
|
|
549
|
+
const entry = this.#queue.shift()
|
|
550
|
+
entry.started = true
|
|
551
|
+
entry.signal?.removeEventListener("abort", entry.onAbort)
|
|
552
|
+
if (entry.signal?.aborted === true) {
|
|
553
|
+
entry.reject(abortReason(entry.signal))
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
this.#active += 1
|
|
558
|
+
void Promise.resolve()
|
|
559
|
+
.then(() => {
|
|
560
|
+
if (entry.signal?.aborted === true) throw abortReason(entry.signal)
|
|
561
|
+
return entry.task()
|
|
562
|
+
})
|
|
563
|
+
.then(entry.resolve, entry.reject)
|
|
564
|
+
.finally(() => {
|
|
565
|
+
this.#active -= 1
|
|
566
|
+
if (this.#disposedReason !== undefined && this.#active === 0) {
|
|
567
|
+
for (const resolve of this.#idleWaiters.splice(0)) resolve()
|
|
568
|
+
} else {
|
|
569
|
+
this.#drain()
|
|
570
|
+
}
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function discard(response) {
|
|
577
|
+
response.resume?.()
|
|
578
|
+
response.destroy?.()
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Download one remote image through a DNS-pinned, redirect-aware request path.
|
|
583
|
+
* Network dependencies are injectable so the security boundary can be tested
|
|
584
|
+
* without contacting external hosts.
|
|
585
|
+
*/
|
|
586
|
+
export async function downloadRemoteImage(input, options = {}) {
|
|
587
|
+
const policy = resolveRemoteImagePolicy(options.policy)
|
|
588
|
+
const lookup = options.lookup ?? dnsLookup
|
|
589
|
+
const openResponse = options.openResponse ?? openNodeResponse
|
|
590
|
+
let current = assertRemoteUrl(input)
|
|
591
|
+
const active = deadline(options.signal, policy.timeoutMs)
|
|
592
|
+
let redirects = 0
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
while (true) {
|
|
596
|
+
if (active.signal.aborted) throw abortReason(active.signal)
|
|
597
|
+
const addresses = await resolveAddresses(current, lookup, active.signal)
|
|
598
|
+
if (active.signal.aborted) throw abortReason(active.signal)
|
|
599
|
+
|
|
600
|
+
let response
|
|
601
|
+
try {
|
|
602
|
+
response = await awaitWithSignal(
|
|
603
|
+
Promise.resolve().then(() => openResponse(current, {
|
|
604
|
+
headers: {
|
|
605
|
+
accept: policy.mediaTypes.join(", "),
|
|
606
|
+
"accept-encoding": "gzip, deflate, br",
|
|
607
|
+
"user-agent": "dsh-codex remote-image",
|
|
608
|
+
},
|
|
609
|
+
lookup: pinnedLookup(current.hostname, addresses),
|
|
610
|
+
signal: active.signal,
|
|
611
|
+
})),
|
|
612
|
+
active.signal,
|
|
613
|
+
)
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (active.signal.aborted) throw abortReason(active.signal)
|
|
616
|
+
if (error instanceof RemoteImageInputError) throw error
|
|
617
|
+
throw new RemoteImageInputError("remote image request failed", "NETWORK", { cause: error })
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const status = response.statusCode
|
|
621
|
+
if (REDIRECT_STATUSES.has(status)) {
|
|
622
|
+
const location = headerValue(response.headers, "location")
|
|
623
|
+
discard(response)
|
|
624
|
+
if (location === undefined || location.length === 0 || location.length > MAX_LOCATION_LENGTH) {
|
|
625
|
+
throw new RemoteImageInputError("remote image redirect location is missing or invalid", "INVALID_REDIRECT")
|
|
626
|
+
}
|
|
627
|
+
if (redirects >= policy.maxRedirects) {
|
|
628
|
+
throw new RemoteImageInputError("remote image exceeded the redirect limit", "TOO_MANY_REDIRECTS")
|
|
629
|
+
}
|
|
630
|
+
current = assertRemoteUrl(location, current)
|
|
631
|
+
redirects += 1
|
|
632
|
+
continue
|
|
633
|
+
}
|
|
634
|
+
if (status !== 200) {
|
|
635
|
+
discard(response)
|
|
636
|
+
throw new RemoteImageInputError(`remote image request returned HTTP ${String(status)}`, "HTTP_STATUS")
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
const mediaType = responseMediaType(response, policy.mediaTypes)
|
|
641
|
+
const encoded = await awaitWithSignal(
|
|
642
|
+
readBounded(response, policy.maxBytes, active.signal),
|
|
643
|
+
active.signal,
|
|
644
|
+
)
|
|
645
|
+
const data = await awaitWithSignal(
|
|
646
|
+
decodeBody(encoded, response, policy.maxBytes),
|
|
647
|
+
active.signal,
|
|
648
|
+
)
|
|
649
|
+
if (data.byteLength === 0) {
|
|
650
|
+
throw new RemoteImageInputError("remote image response is empty", "INVALID_RESPONSE")
|
|
651
|
+
}
|
|
652
|
+
return Object.freeze({
|
|
653
|
+
data: Uint8Array.from(data),
|
|
654
|
+
mediaType,
|
|
655
|
+
name: safeImageName(current, mediaType),
|
|
656
|
+
})
|
|
657
|
+
} catch (error) {
|
|
658
|
+
response.destroy?.()
|
|
659
|
+
if (active.signal.aborted) throw abortReason(active.signal)
|
|
660
|
+
if (error instanceof RemoteImageInputError) throw error
|
|
661
|
+
throw new RemoteImageInputError("remote image response failed", "NETWORK", { cause: error })
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} finally {
|
|
665
|
+
active.dispose()
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function attachmentPolicy(attachments, policy) {
|
|
670
|
+
if (attachments === null || typeof attachments !== "object" || typeof attachments.saveImage !== "function") {
|
|
671
|
+
throw new RemoteImageInputError("durable attachment service is unavailable", "ATTACHMENT_UNAVAILABLE")
|
|
672
|
+
}
|
|
673
|
+
const limits = attachments.imageLimits
|
|
674
|
+
if (limits === null || typeof limits !== "object") {
|
|
675
|
+
throw new RemoteImageInputError("durable attachment limits are unavailable", "ATTACHMENT_UNAVAILABLE")
|
|
676
|
+
}
|
|
677
|
+
const maxBytes = Math.min(
|
|
678
|
+
policy.maxBytes,
|
|
679
|
+
positiveSafeInteger(limits.maxImageBytes, "attachments.imageLimits.maxImageBytes"),
|
|
680
|
+
positiveSafeInteger(limits.maxMessageImageBytes, "attachments.imageLimits.maxMessageImageBytes"),
|
|
681
|
+
)
|
|
682
|
+
const deployed = Array.isArray(limits.mediaTypes) ? limits.mediaTypes : []
|
|
683
|
+
const mediaTypes = policy.mediaTypes.filter((mediaType) => deployed.includes(mediaType))
|
|
684
|
+
if (mediaTypes.length === 0) {
|
|
685
|
+
throw new RemoteImageInputError("deployment accepts no supported remote image media types", "UNSUPPORTED_MEDIA_TYPE")
|
|
686
|
+
}
|
|
687
|
+
return { ...policy, maxBytes, mediaTypes }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Download, validate, and commit one URL image before exposing its model block. */
|
|
691
|
+
export async function saveRemoteImage(attachments, input, options = {}) {
|
|
692
|
+
const policy = attachmentPolicy(attachments, resolveRemoteImagePolicy(options.policy))
|
|
693
|
+
const downloaded = await downloadRemoteImage(input, { ...options, policy })
|
|
694
|
+
if (options.signal?.aborted) throw abortReason(options.signal)
|
|
695
|
+
const attachment = await attachments.saveImage(downloaded)
|
|
696
|
+
if (options.signal?.aborted) throw abortReason(options.signal)
|
|
697
|
+
return Object.freeze({
|
|
698
|
+
attachment,
|
|
699
|
+
block: Object.freeze({ type: "image", attachment }),
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function isHttpUrlString(value) {
|
|
704
|
+
return typeof value === "string" && /^https?:\/\//iu.test(value.trim())
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function displayRemoteUrl(input) {
|
|
708
|
+
const url = assertRemoteUrl(input)
|
|
709
|
+
url.search = ""
|
|
710
|
+
url.hash = ""
|
|
711
|
+
return url.toString()
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function imageValue(attachment) {
|
|
715
|
+
return {
|
|
716
|
+
attachmentId: attachment.attachmentId,
|
|
717
|
+
mediaType: attachment.mediaType,
|
|
718
|
+
bytes: attachment.bytes,
|
|
719
|
+
width: attachment.width,
|
|
720
|
+
height: attachment.height,
|
|
721
|
+
...(attachment.name === undefined ? {} : { name: attachment.name }),
|
|
722
|
+
...(attachment.originalDimensions === undefined
|
|
723
|
+
? {}
|
|
724
|
+
: { originalDimensions: { ...attachment.originalDimensions } }),
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function activeModelRoute(exec) {
|
|
729
|
+
const routed = exec.agent?.session.requestHeader()?.config
|
|
730
|
+
return {
|
|
731
|
+
provider: routed?.provider ?? exec.agent?.options.provider,
|
|
732
|
+
model: routed?.model ?? exec.agent?.options.model,
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function assertImageCapableRoute(ctx, exec, requestedUrl, signal = exec.signal) {
|
|
737
|
+
const { provider, model } = activeModelRoute(exec)
|
|
738
|
+
const llm = ctx.get("llm")
|
|
739
|
+
if (provider === undefined || model === undefined || llm === undefined) {
|
|
740
|
+
throw new Error(`cannot read "${displayRemoteUrl(requestedUrl)}" as an image: the current model route could not be resolved`)
|
|
741
|
+
}
|
|
742
|
+
const active = await awaitWithSignal(
|
|
743
|
+
Promise.resolve().then(() => llm.resolveModelInfo(provider, model, signal)),
|
|
744
|
+
signal,
|
|
745
|
+
)
|
|
746
|
+
if (active.inputModalities === undefined || !active.inputModalities.includes("image")) {
|
|
747
|
+
throw new Error(`cannot read "${displayRemoteUrl(requestedUrl)}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`)
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Build a public `tools/execute` middleware that handles only HTTP(S) values
|
|
753
|
+
* passed to the existing `read_image` tool. The tool registry validates this
|
|
754
|
+
* value against the original output schema and invokes its original renderer;
|
|
755
|
+
* ordinary filesystem paths always delegate to `next()` unchanged.
|
|
756
|
+
*/
|
|
757
|
+
export function createReadImageUrlMiddleware(ctx, options = {}) {
|
|
758
|
+
if (ctx === null || typeof ctx !== "object" || typeof ctx.get !== "function") {
|
|
759
|
+
throw new TypeError("a Cordis context with public service lookup is required")
|
|
760
|
+
}
|
|
761
|
+
const limiter = new RemoteImageWorkLimiter()
|
|
762
|
+
const lifetime = new AbortController()
|
|
763
|
+
let disposal
|
|
764
|
+
const middleware = async (exec, next) => {
|
|
765
|
+
const requested = exec.name === "read_image" ? exec.arguments?.file_path : undefined
|
|
766
|
+
if (!isHttpUrlString(requested)) return next()
|
|
767
|
+
if (activeModelRoute(exec).provider !== CODEX_ROUTE_ID) return next()
|
|
768
|
+
|
|
769
|
+
const normalized = requested.trim()
|
|
770
|
+
const active = linkedAbortSignal(exec.signal, lifetime.signal)
|
|
771
|
+
try {
|
|
772
|
+
if (active.signal.aborted) throw abortReason(active.signal)
|
|
773
|
+
return await limiter.run(active.signal, async () => {
|
|
774
|
+
await assertImageCapableRoute(ctx, exec, normalized, active.signal)
|
|
775
|
+
const attachments = ctx.get("attachments")
|
|
776
|
+
const { attachment } = await saveRemoteImage(attachments, normalized, {
|
|
777
|
+
...options,
|
|
778
|
+
signal: active.signal,
|
|
779
|
+
})
|
|
780
|
+
return {
|
|
781
|
+
isError: false,
|
|
782
|
+
value: {
|
|
783
|
+
path: displayRemoteUrl(normalized),
|
|
784
|
+
image: imageValue(attachment),
|
|
785
|
+
},
|
|
786
|
+
}
|
|
787
|
+
})
|
|
788
|
+
} finally {
|
|
789
|
+
active.dispose()
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
middleware.dispose = () => {
|
|
793
|
+
if (disposal !== undefined) return disposal
|
|
794
|
+
const reason = new RemoteImageInputError("remote image middleware was disposed", "ABORTED")
|
|
795
|
+
const pending = limiter.dispose(reason)
|
|
796
|
+
disposal = Promise.resolve(pending)
|
|
797
|
+
lifetime.abort(reason)
|
|
798
|
+
return disposal
|
|
799
|
+
}
|
|
800
|
+
return middleware
|
|
801
|
+
}
|