opencode-plugboard 0.9.0 → 0.11.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/package.json +11 -2
- package/src/abort.ts +26 -0
- package/src/config.ts +19 -7
- package/src/discovery.ts +2 -2
- package/src/gemini/request.ts +445 -0
- package/src/gemini/response.ts +234 -0
- package/src/gemini/route.ts +163 -0
- package/src/http.ts +5 -21
- package/src/openai/route.ts +14 -6
- package/src/responses/response.ts +9 -2
- package/src/responses/route.ts +1 -1
- package/src/runtime.ts +11 -40
- package/src/session/caller.ts +1 -11
- package/src/session/media.ts +60 -110
package/src/session/caller.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { randomUUID } from "node:crypto"
|
|
20
|
+
import { raceAbort } from "../abort.js"
|
|
20
21
|
import { APP_NAME_PATTERN, DEFAULT_APP } from "../config.js"
|
|
21
22
|
import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
|
|
22
23
|
import { log, logError } from "../log.js"
|
|
@@ -72,17 +73,6 @@ function checkPreparation(request: PlugRequest, images: number, maxTools: number
|
|
|
72
73
|
return undefined
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/** Settles with `promise`, or rejects as soon as `signal` aborts. Request side only: the promise itself keeps running. */
|
|
76
|
-
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
77
|
-
promise.catch(() => {}) // a late rejection after the race was lost must not become unhandled
|
|
78
|
-
if (signal.aborted) return Promise.reject(signal.reason)
|
|
79
|
-
return new Promise<T>((resolve, reject) => {
|
|
80
|
-
const onAbort = () => reject(signal.reason)
|
|
81
|
-
signal.addEventListener("abort", onAbort, { once: true })
|
|
82
|
-
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort))
|
|
83
|
-
})
|
|
84
|
-
}
|
|
85
|
-
|
|
86
76
|
export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
87
77
|
const { port, sessions, maxTools, ensureBackend } = deps
|
|
88
78
|
const remoteImages = deps.remoteImages ?? false
|
package/src/session/media.ts
CHANGED
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
* server's own network (#84): the host is resolved first, every address it resolves to must be a
|
|
10
10
|
* public one (loopback, private, link-local, CGNAT, ULA, multicast, reserved and mapped or
|
|
11
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
|
|
13
|
-
* name, so a second DNS answer cannot redirect it (DNS
|
|
14
|
-
*
|
|
12
|
+
* host or the address, and the request then connects to the addresses that were checked (tried in
|
|
13
|
+
* order until one accepts), not to the name, so a second DNS answer cannot redirect it (DNS
|
|
14
|
+
* rebinding). Addresses are classified by ipaddr.js. The caller sees one generic error for every
|
|
15
|
+
* fetch failure, whatever the cause.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { lookup } from "node:dns/promises"
|
|
18
19
|
import { request as httpRequest, type IncomingMessage } from "node:http"
|
|
19
20
|
import { request as httpsRequest } from "node:https"
|
|
20
21
|
import { isIP } from "node:net"
|
|
22
|
+
import ipaddr from "ipaddr.js"
|
|
21
23
|
import type { PlugRequest } from "../contract.js"
|
|
22
24
|
import { log } from "../log.js"
|
|
23
25
|
|
|
@@ -49,127 +51,64 @@ export class ImageError extends Error {
|
|
|
49
51
|
|
|
50
52
|
// ---- addresses
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
readonly family: 4 | 6
|
|
54
|
-
readonly bytes: ReadonlyArray<number>
|
|
55
|
-
}
|
|
54
|
+
type Ip = ipaddr.IPv4 | ipaddr.IPv6
|
|
56
55
|
|
|
57
|
-
/**
|
|
56
|
+
/** Strict dotted IPv4 or any IPv6 text form (compressed, with an embedded IPv4 tail, with a zone). Odd IPv4 spellings (`0x7f.1`) never get here: a URL host is normalized by `new URL` first. */
|
|
58
57
|
function parseIp(text: string): Ip | undefined {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
58
|
+
if (isIP(text) === 0) return undefined
|
|
59
|
+
try {
|
|
60
|
+
return ipaddr.parse(text)
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined
|
|
92
63
|
}
|
|
93
|
-
return true
|
|
94
64
|
}
|
|
95
65
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
}
|
|
66
|
+
/** `address/bits` match; false for another family (ipaddr.js throws on one). */
|
|
67
|
+
const inNet = (ip: Ip, net: Ip, bits: number): boolean => ip.kind() === net.kind() && ip.match(net, bits)
|
|
105
68
|
|
|
106
|
-
const
|
|
69
|
+
const NAT64 = ipaddr.IPv6.parseCIDR("64:ff9b::/96")
|
|
70
|
+
/** `::/96`: unspecified, loopback and the deprecated IPv4-compatible form (`::7f00:1`), which ipaddr.js calls unicast. */
|
|
71
|
+
const IPV4_COMPATIBLE = ipaddr.IPv6.parseCIDR("::/96")
|
|
107
72
|
|
|
108
|
-
|
|
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
|
-
])
|
|
73
|
+
const v4From = (high: number, low: number) => new ipaddr.IPv4([high >> 8, high & 0xff, low >> 8, low & 0xff])
|
|
125
74
|
|
|
126
75
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
76
|
+
* A public unicast address. ipaddr.js names the special ranges (loopback, private, link-local, CGNAT,
|
|
77
|
+
* multicast, reserved and documentation, ULA, Teredo, local-use NAT64, IPv4-mapped, ...); only
|
|
78
|
+
* `unicast` passes. NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) embed an IPv4 address, which is
|
|
79
|
+
* checked as IPv4 instead of being refused outright (a DNS64 network answers with NAT64 addresses
|
|
80
|
+
* for ordinary public hosts). An IPv4-mapped address is never a real answer for a public host.
|
|
130
81
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
82
|
+
export function isPublicAddress(text: string): boolean {
|
|
83
|
+
const ip = parseIp(text)
|
|
84
|
+
return ip !== undefined && isPublic(ip)
|
|
153
85
|
}
|
|
154
86
|
|
|
155
87
|
function isPublic(ip: Ip): boolean {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
88
|
+
if (ip instanceof ipaddr.IPv4) return ip.range() === "unicast"
|
|
89
|
+
const range = ip.range()
|
|
90
|
+
const inner = range === "6to4" ? v4From(ip.parts[1]!, ip.parts[2]!) : ip.match(NAT64) ? v4From(ip.parts[6]!, ip.parts[7]!) : undefined
|
|
91
|
+
if (inner) return isPublic(inner)
|
|
92
|
+
return range === "unicast" && !ip.match(IPV4_COMPATIBLE)
|
|
159
93
|
}
|
|
160
94
|
|
|
161
95
|
// ---- allowlist
|
|
162
96
|
|
|
163
97
|
/** 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
|
|
98
|
+
type AllowEntry = { readonly kind: "host"; readonly name: string } | { readonly kind: "net"; readonly address: Ip; readonly bits: number }
|
|
165
99
|
|
|
166
100
|
const HOSTNAME = /^(?:\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
|
|
167
101
|
|
|
168
102
|
/** Undefined when `entry` is none of the three forms. */
|
|
169
103
|
export function parseAllowEntry(entry: string): AllowEntry | undefined {
|
|
170
104
|
const text = entry.trim().toLowerCase()
|
|
171
|
-
const
|
|
172
|
-
|
|
105
|
+
const [address = "", length, extra] = text.split("/")
|
|
106
|
+
const ip = parseIp(address)
|
|
107
|
+
if (ip && extra === undefined) {
|
|
108
|
+
const max = ip instanceof ipaddr.IPv4 ? 32 : 128
|
|
109
|
+
if (length === undefined) return { kind: "net", address: ip, bits: max }
|
|
110
|
+
if (/^\d{1,3}$/.test(length) && Number(length) <= max) return { kind: "net", address: ip, bits: Number(length) }
|
|
111
|
+
}
|
|
173
112
|
return HOSTNAME.test(text) && !text.includes("/") ? { kind: "host", name: text } : undefined
|
|
174
113
|
}
|
|
175
114
|
|
|
@@ -190,11 +129,12 @@ function resolveAddresses(host: string, signal: AbortSignal): Promise<string[]>
|
|
|
190
129
|
}
|
|
191
130
|
|
|
192
131
|
/**
|
|
193
|
-
* Resolve the URL's host and
|
|
194
|
-
* to has to be allowed (a name that answers with one public and one private
|
|
195
|
-
* so
|
|
132
|
+
* Resolve the URL's host and return the addresses to connect to, in resolver order. Every address
|
|
133
|
+
* the name resolves to has to be allowed (a name that answers with one public and one private
|
|
134
|
+
* address is refused), so a pinned one cannot be a surprise. Throws the generic fetch error when
|
|
135
|
+
* anything is refused.
|
|
196
136
|
*/
|
|
197
|
-
async function
|
|
137
|
+
async function checkedAddresses(url: URL, allow: ReadonlyArray<AllowEntry>, signal: AbortSignal): Promise<string[]> {
|
|
198
138
|
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
|
199
139
|
const literal = parseIp(host)
|
|
200
140
|
const addresses = literal ? [host] : await resolveAddresses(host, signal)
|
|
@@ -202,14 +142,14 @@ async function pickAddress(url: URL, allow: ReadonlyArray<AllowEntry>, signal: A
|
|
|
202
142
|
const permitted = (address: string): boolean => {
|
|
203
143
|
const ip = parseIp(address)
|
|
204
144
|
if (!ip) return false
|
|
205
|
-
return nameAllowed || isPublic(ip) || allow.some((entry) => entry.kind === "net" &&
|
|
145
|
+
return nameAllowed || isPublic(ip) || allow.some((entry) => entry.kind === "net" && inNet(ip, entry.address, entry.bits))
|
|
206
146
|
}
|
|
207
147
|
if (addresses.length === 0 || !addresses.every(permitted)) {
|
|
208
148
|
// The log says which kind of refusal, never the host or the URL.
|
|
209
149
|
log("media.remote_refused", { reason: addresses.length === 0 ? "no_address" : "address_not_allowed" })
|
|
210
150
|
throw FETCH_FAILED()
|
|
211
151
|
}
|
|
212
|
-
return addresses
|
|
152
|
+
return addresses
|
|
213
153
|
}
|
|
214
154
|
|
|
215
155
|
/** 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. */
|
|
@@ -239,6 +179,18 @@ function get(url: URL, address: string, signal: AbortSignal): Promise<IncomingMe
|
|
|
239
179
|
})
|
|
240
180
|
}
|
|
241
181
|
|
|
182
|
+
/** The first connection that succeeds, trying each checked address in order (#134); only a connection failure moves on, never a response. */
|
|
183
|
+
async function getFirst(url: URL, addresses: ReadonlyArray<string>, signal: AbortSignal): Promise<IncomingMessage> {
|
|
184
|
+
for (const address of addresses) {
|
|
185
|
+
try {
|
|
186
|
+
return await get(url, address, signal)
|
|
187
|
+
} catch {
|
|
188
|
+
if (signal.aborted) break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw FETCH_FAILED()
|
|
192
|
+
}
|
|
193
|
+
|
|
242
194
|
// ---- preparation
|
|
243
195
|
|
|
244
196
|
async function toDataUrl(url: string, signal: AbortSignal, remoteImages: RemoteImages, budget: Budget): Promise<string> {
|
|
@@ -260,12 +212,10 @@ async function toDataUrl(url: string, signal: AbortSignal, remoteImages: RemoteI
|
|
|
260
212
|
throw FETCH_FAILED()
|
|
261
213
|
}
|
|
262
214
|
const allow = typeof remoteImages === "object" ? remoteImages.allow.flatMap((entry) => parseAllowEntry(entry) ?? []) : []
|
|
263
|
-
const
|
|
215
|
+
const addresses = await checkedAddresses(target, allow, bounded).catch((error: unknown) => {
|
|
264
216
|
throw error instanceof ImageError ? error : FETCH_FAILED()
|
|
265
217
|
})
|
|
266
|
-
const response = await
|
|
267
|
-
throw FETCH_FAILED()
|
|
268
|
-
})
|
|
218
|
+
const response = await getFirst(target, addresses, bounded)
|
|
269
219
|
// A redirect is never followed (it could point inside); anything but a plain success is a failed fetch.
|
|
270
220
|
if (response.statusCode === undefined || response.statusCode < 200 || response.statusCode > 299) {
|
|
271
221
|
response.destroy()
|