opencode-plugboard 0.5.0 → 0.7.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 +5 -6
- package/package.json +1 -1
- package/src/anthropic/request.ts +343 -0
- package/src/anthropic/response.ts +221 -0
- package/src/anthropic/route.ts +148 -0
- package/src/config.ts +27 -5
- package/src/openai/route.ts +8 -4
- package/src/runtime.ts +43 -10
- package/src/session/caller.ts +5 -5
- package/src/session/events.ts +60 -12
- package/src/session/history.ts +18 -6
- package/src/session/media.ts +256 -23
package/src/session/media.ts
CHANGED
|
@@ -4,20 +4,39 @@
|
|
|
4
4
|
* Prompt files must be inline `data:` URLs. http(s) images are fetched once, only when the
|
|
5
5
|
* `remoteImages` option is on, with no redirects, a 20 s timeout and a 20 MB cap. All images of
|
|
6
6
|
* one request together may hold at most 50 MB (`images_too_large`), data URLs and fetched ones alike.
|
|
7
|
+
*
|
|
8
|
+
* A remote fetch is a request the server makes on a caller's behalf, so it must not reach the
|
|
9
|
+
* server's own network (#84): the host is resolved first, every address it resolves to must be a
|
|
10
|
+
* public one (loopback, private, link-local, CGNAT, ULA, multicast, reserved and mapped or
|
|
11
|
+
* translated forms of those are refused) unless the operator's `remoteImages.allow` list names the
|
|
12
|
+
* host or the address, and the request then connects to the address that was checked, not to the
|
|
13
|
+
* name, so a second DNS answer cannot redirect it (DNS rebinding). The caller sees one generic
|
|
14
|
+
* error for every fetch failure, whatever the cause.
|
|
7
15
|
*/
|
|
8
16
|
|
|
17
|
+
import { lookup } from "node:dns/promises"
|
|
18
|
+
import { request as httpRequest, type IncomingMessage } from "node:http"
|
|
19
|
+
import { request as httpsRequest } from "node:https"
|
|
20
|
+
import { isIP } from "node:net"
|
|
9
21
|
import type { PlugRequest } from "../contract.js"
|
|
22
|
+
import { log } from "../log.js"
|
|
10
23
|
|
|
11
24
|
const MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024
|
|
12
25
|
/** Aggregate size of the prepared data URLs of one request. */
|
|
13
26
|
const MAX_TOTAL_IMAGE_BYTES = 50 * 1024 * 1024
|
|
27
|
+
const FETCH_TIMEOUT_MS = 20_000
|
|
14
28
|
const TOO_MANY = () => new ImageError("images_too_large", "The request's images are larger than 50 MB in total")
|
|
29
|
+
/** The one message for every failed fetch (blocked address, DNS, refused, timeout, bad status): no oracle for the server's network. */
|
|
30
|
+
const FETCH_FAILED = () => new ImageError("image_fetch_failed", "Could not fetch image")
|
|
15
31
|
|
|
16
32
|
/** What one request's images may still use, in data URL characters (== bytes held). */
|
|
17
33
|
interface Budget {
|
|
18
34
|
remaining: number
|
|
19
35
|
}
|
|
20
36
|
|
|
37
|
+
/** The `remoteImages` option as the session caller receives it: off, on (public addresses only), or on with an allowlist. */
|
|
38
|
+
export type RemoteImages = boolean | { readonly allow: ReadonlyArray<string> }
|
|
39
|
+
|
|
21
40
|
/** A caller image that cannot be used; `code` becomes the error code of the 400 response. */
|
|
22
41
|
export class ImageError extends Error {
|
|
23
42
|
constructor(
|
|
@@ -28,41 +47,255 @@ export class ImageError extends Error {
|
|
|
28
47
|
}
|
|
29
48
|
}
|
|
30
49
|
|
|
31
|
-
|
|
50
|
+
// ---- addresses
|
|
51
|
+
|
|
52
|
+
interface Ip {
|
|
53
|
+
readonly family: 4 | 6
|
|
54
|
+
readonly bytes: ReadonlyArray<number>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Dotted IPv4 or any IPv6 text form (compressed, with an embedded IPv4 tail, with a zone) to bytes. */
|
|
58
|
+
function parseIp(text: string): Ip | undefined {
|
|
59
|
+
const family = isIP(text)
|
|
60
|
+
if (family === 4) return { family, bytes: text.split(".").map(Number) }
|
|
61
|
+
if (family !== 6) return undefined
|
|
62
|
+
let head = text.split("%")[0]!
|
|
63
|
+
const dot = head.lastIndexOf(".")
|
|
64
|
+
if (dot !== -1) {
|
|
65
|
+
const colon = head.lastIndexOf(":")
|
|
66
|
+
const v4 = head.slice(colon + 1).split(".").map(Number)
|
|
67
|
+
head = `${head.slice(0, colon + 1)}${((v4[0]! << 8) | v4[1]!).toString(16)}:${((v4[2]! << 8) | v4[3]!).toString(16)}`
|
|
68
|
+
}
|
|
69
|
+
const [left = "", right = ""] = head.split("::")
|
|
70
|
+
const first = left === "" ? [] : left.split(":")
|
|
71
|
+
const last = head.includes("::") ? (right === "" ? [] : right.split(":")) : []
|
|
72
|
+
const groups = head.includes("::") ? [...first, ...Array<string>(8 - first.length - last.length).fill("0"), ...last] : head.split(":")
|
|
73
|
+
const bytes: number[] = []
|
|
74
|
+
for (const group of groups) {
|
|
75
|
+
const value = Number.parseInt(group, 16)
|
|
76
|
+
bytes.push(value >> 8, value & 0xff)
|
|
77
|
+
}
|
|
78
|
+
return { family, bytes }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Cidr {
|
|
82
|
+
readonly ip: Ip
|
|
83
|
+
readonly bits: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function inCidr(ip: Ip, cidr: Cidr): boolean {
|
|
87
|
+
if (ip.family !== cidr.ip.family) return false
|
|
88
|
+
let left = cidr.bits
|
|
89
|
+
for (let i = 0; left > 0; i++, left -= 8) {
|
|
90
|
+
const mask = left >= 8 ? 0xff : (0xff << (8 - left)) & 0xff
|
|
91
|
+
if ((ip.bytes[i]! & mask) !== (cidr.ip.bytes[i]! & mask)) return false
|
|
92
|
+
}
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseCidr(text: string): Cidr | undefined {
|
|
97
|
+
const [address = "", length, extra] = text.split("/")
|
|
98
|
+
const ip = parseIp(address)
|
|
99
|
+
if (!ip || extra !== undefined) return undefined
|
|
100
|
+
const max = ip.family === 4 ? 32 : 128
|
|
101
|
+
if (length === undefined) return { ip, bits: max }
|
|
102
|
+
const bits = Number(length)
|
|
103
|
+
return /^\d{1,3}$/.test(length) && bits <= max ? { ip, bits } : undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const cidrs = (list: string[]): Cidr[] => list.map((entry) => parseCidr(entry)!)
|
|
107
|
+
|
|
108
|
+
/** IPv4 that is not a public unicast address. */
|
|
109
|
+
const BLOCKED_V4 = cidrs([
|
|
110
|
+
"0.0.0.0/8", // "this network"
|
|
111
|
+
"10.0.0.0/8",
|
|
112
|
+
"100.64.0.0/10", // CGNAT
|
|
113
|
+
"127.0.0.0/8", // loopback
|
|
114
|
+
"169.254.0.0/16", // link-local, cloud metadata
|
|
115
|
+
"172.16.0.0/12",
|
|
116
|
+
"192.0.0.0/24", // IETF protocol assignments
|
|
117
|
+
"192.0.2.0/24", // documentation
|
|
118
|
+
"192.168.0.0/16",
|
|
119
|
+
"198.18.0.0/15", // benchmarking
|
|
120
|
+
"198.51.100.0/24", // documentation
|
|
121
|
+
"203.0.113.0/24", // documentation
|
|
122
|
+
"224.0.0.0/4", // multicast
|
|
123
|
+
"240.0.0.0/4", // reserved, broadcast
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* IPv6 that is not a public unicast address. NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) are
|
|
128
|
+
* not listed: they embed an IPv4 address, which `isPublic` checks as IPv4 (a DNS64 network answers
|
|
129
|
+
* with NAT64 addresses for ordinary public hosts).
|
|
130
|
+
*/
|
|
131
|
+
const BLOCKED_V6 = cidrs([
|
|
132
|
+
"::/96", // unspecified, loopback, deprecated IPv4-compatible
|
|
133
|
+
"::ffff:0:0/96", // IPv4-mapped: never a real answer for a public host
|
|
134
|
+
"64:ff9b:1::/48", // local-use NAT64
|
|
135
|
+
"100::/64", // discard
|
|
136
|
+
"2001::/32", // Teredo
|
|
137
|
+
"2001:db8::/32", // documentation
|
|
138
|
+
"fc00::/7", // unique local
|
|
139
|
+
"fe80::/10", // link-local
|
|
140
|
+
"fec0::/10", // site-local (deprecated)
|
|
141
|
+
"ff00::/8", // multicast
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
/** The IPv4 address embedded in a NAT64 or 6to4 address (or a mapped one), if it is one. */
|
|
145
|
+
function embeddedV4(ip: Ip): Ip | undefined {
|
|
146
|
+
if (ip.family !== 6) return undefined
|
|
147
|
+
const b = ip.bytes
|
|
148
|
+
const mapped = b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff
|
|
149
|
+
const nat64 = b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && b.slice(4, 12).every((x) => x === 0)
|
|
150
|
+
if (mapped || nat64) return { family: 4, bytes: b.slice(12, 16) }
|
|
151
|
+
if (b[0] === 0x20 && b[1] === 0x02) return { family: 4, bytes: b.slice(2, 6) }
|
|
152
|
+
return undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isPublic(ip: Ip): boolean {
|
|
156
|
+
const inner = embeddedV4(ip)
|
|
157
|
+
if (inner && !isPublic(inner)) return false
|
|
158
|
+
return !(ip.family === 4 ? BLOCKED_V4 : BLOCKED_V6).some((cidr) => inCidr(ip, cidr))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- allowlist
|
|
162
|
+
|
|
163
|
+
/** One `remoteImages.allow` entry: a host name (`images.internal`, or `*.internal` for subdomains), an IP address, or a CIDR range. */
|
|
164
|
+
type AllowEntry = { readonly kind: "host"; readonly name: string } | { readonly kind: "net"; readonly cidr: Cidr }
|
|
165
|
+
|
|
166
|
+
const HOSTNAME = /^(?:\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
|
|
167
|
+
|
|
168
|
+
/** Undefined when `entry` is none of the three forms. */
|
|
169
|
+
export function parseAllowEntry(entry: string): AllowEntry | undefined {
|
|
170
|
+
const text = entry.trim().toLowerCase()
|
|
171
|
+
const cidr = parseCidr(text)
|
|
172
|
+
if (cidr) return { kind: "net", cidr }
|
|
173
|
+
return HOSTNAME.test(text) && !text.includes("/") ? { kind: "host", name: text } : undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const hostMatches = (entry: { readonly name: string }, host: string): boolean =>
|
|
177
|
+
entry.name.startsWith("*.") ? host.endsWith(entry.name.slice(1)) : host === entry.name
|
|
178
|
+
|
|
179
|
+
/** All addresses of `host`, in resolver order; rejects when `signal` aborts (the lookup itself cannot be cancelled). */
|
|
180
|
+
function resolveAddresses(host: string, signal: AbortSignal): Promise<string[]> {
|
|
181
|
+
return new Promise((done, fail) => {
|
|
182
|
+
const onAbort = () => fail(signal.reason)
|
|
183
|
+
if (signal.aborted) return onAbort()
|
|
184
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
185
|
+
lookup(host, { all: true, verbatim: true }).then(
|
|
186
|
+
(found) => (signal.removeEventListener("abort", onAbort), done(found.map((entry) => entry.address))),
|
|
187
|
+
(error) => (signal.removeEventListener("abort", onAbort), fail(error)),
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the URL's host and decide which address to connect to. Every address the name resolves
|
|
194
|
+
* to has to be allowed (a name that answers with one public and one private address is refused),
|
|
195
|
+
* so the pinned one cannot be a surprise. Throws the generic fetch error when anything is refused.
|
|
196
|
+
*/
|
|
197
|
+
async function pickAddress(url: URL, allow: ReadonlyArray<AllowEntry>, signal: AbortSignal): Promise<string> {
|
|
198
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
|
199
|
+
const literal = parseIp(host)
|
|
200
|
+
const addresses = literal ? [host] : await resolveAddresses(host, signal)
|
|
201
|
+
const nameAllowed = allow.some((entry) => entry.kind === "host" && hostMatches(entry, host))
|
|
202
|
+
const permitted = (address: string): boolean => {
|
|
203
|
+
const ip = parseIp(address)
|
|
204
|
+
if (!ip) return false
|
|
205
|
+
return nameAllowed || isPublic(ip) || allow.some((entry) => entry.kind === "net" && inCidr(ip, entry.cidr))
|
|
206
|
+
}
|
|
207
|
+
if (addresses.length === 0 || !addresses.every(permitted)) {
|
|
208
|
+
// The log says which kind of refusal, never the host or the URL.
|
|
209
|
+
log("media.remote_refused", { reason: addresses.length === 0 ? "no_address" : "address_not_allowed" })
|
|
210
|
+
throw FETCH_FAILED()
|
|
211
|
+
}
|
|
212
|
+
return addresses[0]!
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** GET `url` over a connection to `address` (already checked), keeping the URL's host for `Host` and TLS. No redirects are followed by the caller. */
|
|
216
|
+
function get(url: URL, address: string, signal: AbortSignal): Promise<IncomingMessage> {
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
const secure = url.protocol === "https:"
|
|
219
|
+
const name = url.hostname.replace(/^\[|\]$/g, "")
|
|
220
|
+
const request = (secure ? httpsRequest : httpRequest)(
|
|
221
|
+
{
|
|
222
|
+
host: address,
|
|
223
|
+
port: url.port === "" ? (secure ? 443 : 80) : Number(url.port),
|
|
224
|
+
path: `${url.pathname}${url.search}`,
|
|
225
|
+
method: "GET",
|
|
226
|
+
agent: false,
|
|
227
|
+
headers: { host: url.host, accept: "image/*", "user-agent": "opencode-plugboard" },
|
|
228
|
+
// Certificate checks use the URL's host name, not the pinned address.
|
|
229
|
+
...(secure && isIP(name) === 0 ? { servername: name } : {}),
|
|
230
|
+
},
|
|
231
|
+
resolve,
|
|
232
|
+
)
|
|
233
|
+
request.on("error", reject)
|
|
234
|
+
const abort = () => request.destroy(new Error("aborted"))
|
|
235
|
+
request.on("close", () => signal.removeEventListener("abort", abort))
|
|
236
|
+
if (signal.aborted) return abort()
|
|
237
|
+
signal.addEventListener("abort", abort, { once: true })
|
|
238
|
+
request.end()
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- preparation
|
|
243
|
+
|
|
244
|
+
async function toDataUrl(url: string, signal: AbortSignal, remoteImages: RemoteImages, budget: Budget): Promise<string> {
|
|
32
245
|
if (url.startsWith("data:")) {
|
|
33
246
|
if (url.length > budget.remaining) throw TOO_MANY()
|
|
34
247
|
return url
|
|
35
248
|
}
|
|
36
249
|
if (!/^https?:\/\//i.test(url)) throw new ImageError("unsupported_image_url", "Unsupported image URL scheme; use a data: URL or an http(s) URL")
|
|
37
|
-
if (
|
|
250
|
+
if (remoteImages === false)
|
|
38
251
|
throw new ImageError("remote_images_disabled", "Remote image URLs are disabled; send a data: URL or set the plugboard `remoteImages` option")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
252
|
+
|
|
253
|
+
// Messages never carry the URL, the status, the content type or the fetch error text: they can hold
|
|
254
|
+
// credentials in the query string or describe the server's network to the caller.
|
|
255
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
|
|
256
|
+
let target: URL
|
|
257
|
+
try {
|
|
258
|
+
target = new URL(url)
|
|
259
|
+
} catch {
|
|
260
|
+
throw FETCH_FAILED()
|
|
261
|
+
}
|
|
262
|
+
const allow = typeof remoteImages === "object" ? remoteImages.allow.flatMap((entry) => parseAllowEntry(entry) ?? []) : []
|
|
263
|
+
const address = await pickAddress(target, allow, bounded).catch((error: unknown) => {
|
|
264
|
+
throw error instanceof ImageError ? error : FETCH_FAILED()
|
|
265
|
+
})
|
|
266
|
+
const response = await get(target, address, bounded).catch(() => {
|
|
267
|
+
throw FETCH_FAILED()
|
|
43
268
|
})
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
269
|
+
// A redirect is never followed (it could point inside); anything but a plain success is a failed fetch.
|
|
270
|
+
if (response.statusCode === undefined || response.statusCode < 200 || response.statusCode > 299) {
|
|
271
|
+
response.destroy()
|
|
272
|
+
throw FETCH_FAILED()
|
|
273
|
+
}
|
|
274
|
+
const mediaType = (response.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase() ?? ""
|
|
275
|
+
if (!mediaType.startsWith("image/")) {
|
|
276
|
+
response.destroy()
|
|
277
|
+
throw new ImageError("invalid_image", "URL did not return an image")
|
|
278
|
+
}
|
|
279
|
+
const declared = Number(response.headers["content-length"])
|
|
48
280
|
// The data URL is 4/3 of the raw size plus a short prefix: stop reading once it cannot fit the budget.
|
|
49
281
|
const budgetRaw = Math.floor((budget.remaining * 3) / 4)
|
|
50
282
|
const limit = Math.min(MAX_REMOTE_IMAGE_BYTES, budgetRaw)
|
|
51
283
|
const tooLarge = () => (limit < MAX_REMOTE_IMAGE_BYTES ? TOO_MANY() : new ImageError("image_too_large", "Image is larger than 20 MB"))
|
|
52
|
-
if (Number.isFinite(declared) && declared > limit)
|
|
53
|
-
|
|
284
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
285
|
+
response.destroy()
|
|
286
|
+
throw tooLarge()
|
|
287
|
+
}
|
|
288
|
+
const chunks: Buffer[] = []
|
|
54
289
|
let size = 0
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
size += value.byteLength
|
|
61
|
-
if (size > limit) {
|
|
62
|
-
void reader.cancel().catch(() => {})
|
|
63
|
-
throw tooLarge()
|
|
290
|
+
try {
|
|
291
|
+
for await (const chunk of response as AsyncIterable<Buffer>) {
|
|
292
|
+
size += chunk.byteLength
|
|
293
|
+
if (size > limit) throw tooLarge()
|
|
294
|
+
chunks.push(chunk)
|
|
64
295
|
}
|
|
65
|
-
|
|
296
|
+
} catch (error) {
|
|
297
|
+
response.destroy()
|
|
298
|
+
throw error instanceof ImageError ? error : FETCH_FAILED()
|
|
66
299
|
}
|
|
67
300
|
const dataUrl = `data:${mediaType};base64,${Buffer.concat(chunks).toString("base64")}`
|
|
68
301
|
if (dataUrl.length > budget.remaining) throw TOO_MANY()
|
|
@@ -73,7 +306,7 @@ async function toDataUrl(url: string, signal: AbortSignal, remoteImages: boolean
|
|
|
73
306
|
export async function prepareImages(
|
|
74
307
|
request: PlugRequest,
|
|
75
308
|
signal: AbortSignal,
|
|
76
|
-
remoteImages:
|
|
309
|
+
remoteImages: RemoteImages,
|
|
77
310
|
): Promise<Array<{ uri: string; name: string }>> {
|
|
78
311
|
const images: Array<{ uri: string; name: string }> = []
|
|
79
312
|
const budget: Budget = { remaining: MAX_TOTAL_IMAGE_BYTES }
|