opencode-plugboard 0.4.1 → 0.6.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.
@@ -23,7 +23,7 @@ import { log, logError } from "../log.js"
23
23
  import { RequestCollector } from "./events.js"
24
24
  import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
25
25
  import { describeError, type SessionPort } from "./host.js"
26
- import { ImageError, prepareImages } from "./media.js"
26
+ import { ImageError, prepareImages, type RemoteImages } from "./media.js"
27
27
  import { REQUEST_PERMISSIONS, seedToolsFor } from "./seeds.js"
28
28
  import type { SessionStore } from "./state.js"
29
29
 
@@ -33,8 +33,8 @@ export interface SessionCallerDeps {
33
33
  readonly sessions: SessionStore
34
34
  /** The seed pool size of the app directories (option `maxTools`); a request needing more tools fails before dispatch. */
35
35
  readonly maxTools: number
36
- /** Allow fetching http(s) image URLs (option `remoteImages`). */
37
- readonly remoteImages?: boolean
36
+ /** Allow fetching http(s) image URLs (option `remoteImages`: off, public addresses only, or with an allowlist). */
37
+ readonly remoteImages?: RemoteImages
38
38
  /**
39
39
  * Make sure the backend of `app` can run request sessions (the app directory's plugin is loaded),
40
40
  * called right before `session.create`. An error ends the request without creating anything.
@@ -85,7 +85,7 @@ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
85
85
 
86
86
  export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
87
87
  const { port, sessions, maxTools, ensureBackend } = deps
88
- const remoteImages = deps.remoteImages === true
88
+ const remoteImages = deps.remoteImages ?? false
89
89
 
90
90
  return {
91
91
  async *stream(request: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
@@ -134,7 +134,7 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
134
134
  if (unavailable) return yield note({ type: "error", error: unavailable })
135
135
  if (context.signal.aborted) return
136
136
 
137
- collector = new RequestCollector({ stopTurn: interrupt })
137
+ collector = new RequestCollector({ stopTurn: interrupt, requestID })
138
138
  const created = await raceAbort(port.create({
139
139
  directory,
140
140
  model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { isPlugError, type PlugError, type PlugEvent, type PlugFinish, type PlugUsage } from "../contract.js"
15
+ import { logError } from "../log.js"
15
16
  import type { BusEvent } from "./host.js"
16
17
  import { FAILURE_EVENT } from "./state.js"
17
18
 
@@ -116,6 +117,8 @@ interface RawError {
116
117
  readonly type?: string
117
118
  readonly message?: string
118
119
  readonly status?: number
120
+ /** Raw provider response. Read for classification and the log only; never copied to the caller. */
121
+ readonly response?: { readonly body?: string }
119
122
  }
120
123
 
121
124
  const total = (...parts: Array<number | undefined>): number | undefined =>
@@ -138,23 +141,56 @@ const toUsage = (tokens?: RawUsage): PlugUsage | undefined =>
138
141
  }
139
142
  : undefined
140
143
 
141
- /** Host `SessionError` (`{ type, message, status?, response? }`) to a `PlugError`. */
144
+ /** Provider wording for "the prompt does not fit the model" (a subset of the host's own matcher; the host does not forward its classification). */
145
+ const CONTEXT_OVERFLOW =
146
+ /context[_ ](?:length|window)|maximum context|prompt (?:is )?too long|too many tokens|token limit|input length.*exceeds|exceeds the (?:available )?context|reduce the length of the messages/i
147
+
148
+ /**
149
+ * Host `SessionError` (`{ type, message, status?, response? }`) to a `PlugError`. Provider and host
150
+ * error text can carry endpoints, account or organization ids, so it never reaches the caller:
151
+ * the caller gets a stable `code` and a fixed message per class. The classes a caller can act on
152
+ * stay distinct (rate limit, quota, context length, provider credentials, timeout, content filter).
153
+ * The raw text goes to the log only (`RequestCollector.failFromHost`).
154
+ */
142
155
  function toPlugError(error: RawError | undefined, fallback: string): PlugError {
143
156
  const status = typeof error?.status === "number" && error.status >= 400 ? error.status : undefined
144
- const message = error?.message || fallback
145
- const base = { message, ...(error?.type ? { code: error.type } : {}) }
146
- if (status === 401) return { status, kind: "authentication", ...base }
147
- if (status === 403) return { status, kind: "permission", ...base }
148
- if (status === 404) return { status, kind: "not_found", ...base }
149
- if (status === 429) return { status, kind: "rate_limit", ...base }
150
- if (status === 408 || status === 504) return { status, kind: "timeout", ...base }
151
- if (status !== undefined && status < 500) return { status, kind: "invalid_request", ...base }
152
- return { status: status ?? 502, kind: "server", ...base }
157
+ const type = error?.type ?? ""
158
+ // Our own constant (the session caller reports a prompt that could not start); the host never uses this type.
159
+ if (type === "prompt_failed") return { status: 502, kind: "server", code: "prompt_failed", message: error?.message || fallback }
160
+ if (type === "provider.rate-limit" || status === 429) {
161
+ return { status: 429, kind: "rate_limit", code: "rate_limited", message: "The model provider is rate limiting requests; retry later" }
162
+ }
163
+ if (type === "provider.quota") {
164
+ return { status: 429, kind: "rate_limit", code: "quota_exceeded", message: "The model provider's usage quota is exhausted" }
165
+ }
166
+ if ((type === "provider.invalid-request" || status === 400 || status === 413) && CONTEXT_OVERFLOW.test(`${error?.message ?? ""}\n${error?.response?.body ?? ""}`)) {
167
+ return { status: 400, kind: "invalid_request", code: "context_length_exceeded", param: "messages", message: "The request is longer than the model's context window" }
168
+ }
169
+ if (type === "provider.auth" || status === 401 || status === 403) {
170
+ const message = "The model provider rejected its credentials; the plugboard host needs to sign in again"
171
+ if (status === 401) return { status, kind: "authentication", code: "provider_auth_failed", message }
172
+ if (status === 403) return { status, kind: "permission", code: "provider_auth_failed", message }
173
+ return { status: 502, kind: "server", code: "provider_auth_failed", message }
174
+ }
175
+ if (type === "provider.content-filter") {
176
+ return { status: 400, kind: "invalid_request", code: "content_filter", message: "The model provider's content filter blocked the request" }
177
+ }
178
+ if (type === "provider.timeout" || status === 408 || status === 504) {
179
+ return { status: 504, kind: "timeout", code: "provider_timeout", message: "The model provider timed out" }
180
+ }
181
+ if (type.startsWith("provider.")) {
182
+ if (status === 404) return { status, kind: "not_found", code: "provider_rejected_request", message: "The model provider rejected the request" }
183
+ if (status !== undefined && status < 500) return { status, kind: "invalid_request", code: "provider_rejected_request", message: "The model provider rejected the request" }
184
+ return { status: status ?? 502, kind: "server", code: "provider_unavailable", message: "The model provider is unavailable or returned an error" }
185
+ }
186
+ return { status: 502, kind: "server", code: "model_run_failed", message: fallback }
153
187
  }
154
188
 
155
189
  export interface CollectorHooks {
156
190
  /** The step ended with tool calls: stop the turn before the runner starts another model step. */
157
191
  readonly stopTurn: () => void
192
+ /** The request's id, for the log line that carries a failure's raw host text. */
193
+ readonly requestID?: string
158
194
  }
159
195
 
160
196
  export class RequestCollector {
@@ -268,11 +304,11 @@ export class RequestCollector {
268
304
  if (this.calls.size > 0 && error?.type === "aborted") return this.finishWith("tool-calls")
269
305
  if (item.finish === "content-filter") return this.finishWith("content-filter")
270
306
  if (error?.type === "aborted" && this.wasCancelled) return this.end()
271
- return this.fail(toPlugError(error, "Model step failed"))
307
+ return this.failFromHost(error, "Model step failed")
272
308
  }
273
309
  case "session.execution.failed":
274
310
  this.executionDone = true
275
- if (!this.finished) this.fail(toPlugError(item.error as RawError | undefined, "Session execution failed"))
311
+ if (!this.finished) this.failFromHost(item.error as RawError | undefined, "Session execution failed")
276
312
  this.end()
277
313
  return
278
314
  case "session.execution.interrupted":
@@ -310,6 +346,18 @@ export class RequestCollector {
310
346
  this.end()
311
347
  }
312
348
 
349
+ /** Fail with the caller-safe mapping of a host error; the raw text is logged here and goes nowhere else. */
350
+ private failFromHost(raw: RawError | undefined, fallback: string): void {
351
+ if (this.finished) return
352
+ logError("model.failed", {
353
+ requestID: this.hooks.requestID,
354
+ type: raw?.type,
355
+ status: raw?.status,
356
+ message: raw?.message?.slice(0, 500),
357
+ })
358
+ this.fail(toPlugError(raw, fallback))
359
+ }
360
+
313
361
  private fail(error: PlugError): void {
314
362
  if (this.finished) return
315
363
  this.emit({ type: "error", error })
@@ -50,9 +50,20 @@ const firstUserText = (request: PlugRequest): string => {
50
50
  return request.model
51
51
  }
52
52
 
53
- /** Session title: the first user text, whitespace-collapsed and cut to 60 characters. */
53
+ /**
54
+ * Control characters that terminal and app UIs act on: C0 (ESC, BEL, CR, ...), DEL, C1 (8-bit CSI),
55
+ * and the bidirectional marks and overrides (ALM, LRM, RLM, U+202A-202E, U+2066-2069) that reorder text.
56
+ * `\t` and `\n` are included here; callers that keep layout use `LAYOUT_SAFE`.
57
+ */
58
+ const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
59
+ const CONTROLS_KEEP_LAYOUT = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
60
+
61
+ /** One display line: whitespace (including line breaks) collapsed to single spaces, then control and bidi characters removed. */
62
+ export const oneLine = (text: string): string => text.replace(/\s+/g, " ").replace(CONTROLS, "").trim()
63
+
64
+ /** Session title: the first user text, one line, control and bidi characters removed, cut to 60 characters. */
54
65
  export const makeTitle = (request: PlugRequest): string => {
55
- const flat = firstUserText(request).replace(/\s+/g, " ").trim()
66
+ const flat = oneLine(firstUserText(request))
56
67
  return flat.length > TITLE_LENGTH ? flat.slice(0, TITLE_LENGTH - 1) + "…" : flat
57
68
  }
58
69
 
@@ -74,16 +85,17 @@ export function renderTranscript(request: PlugRequest): string {
74
85
  lines.push(`[image ${++image}]`)
75
86
  break
76
87
  case "tool-call":
77
- lines.push(`[tool call ${part.name} (${part.id})] ${typeof part.input === "string" ? part.input : JSON.stringify(part.input)}`)
88
+ lines.push(`[tool call ${oneLine(part.name)} (${oneLine(part.id)})] ${typeof part.input === "string" ? part.input : JSON.stringify(part.input)}`)
78
89
  break
79
90
  case "tool-result":
80
- lines.push(`[tool result ${part.name} (${part.id})${part.isError ? " (error)" : ""}] ${part.result}`)
91
+ lines.push(`[tool result ${oneLine(part.name)} (${oneLine(part.id)})${part.isError ? " (error)" : ""}] ${part.result}`)
81
92
  break
82
93
  }
83
94
  }
84
95
  lines.push("")
85
96
  }
86
- if (request.tools.length > 0) lines.push("## tools", request.tools.map((tool) => tool.name).join(", "), "")
87
- const text = lines.join("\n").trim()
97
+ if (request.tools.length > 0) lines.push("## tools", request.tools.map((tool) => oneLine(tool.name)).join(", "), "")
98
+ // The stored text is display only (the model sees the structured request): body text keeps its line breaks and tabs, nothing else that a UI would act on.
99
+ const text = lines.join("\n").replace(CONTROLS_KEEP_LAYOUT, "").trim()
88
100
  return text.length > 0 ? text : "(empty request)"
89
101
  }
@@ -12,7 +12,7 @@
12
12
  import type { Plugin } from "@opencode/plugin/effect"
13
13
  import type { SessionContext } from "@opencode/plugin/effect/session"
14
14
  import { Effect } from "effect"
15
- import type { CatalogModel } from "../contract.js"
15
+ import type { CatalogCost, CatalogModel } from "../contract.js"
16
16
 
17
17
  export type HostContext = Plugin.Context
18
18
 
@@ -132,9 +132,39 @@ export function createHistoryPort(ctx: HostContext): HistoryPort {
132
132
  const record = (value: unknown): Record<string, unknown> | undefined =>
133
133
  typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
134
134
 
135
+ const finite = (value: unknown): number | undefined => (typeof value === "number" && Number.isFinite(value) ? value : undefined)
136
+ const strings = (value: unknown): string[] | undefined =>
137
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : undefined
138
+ const STATUSES = new Set(["alpha", "beta", "deprecated", "active"])
139
+
140
+ /** Host `Model.Cost[]` (USD per million tokens, optional context tier) -> neutral price entries; malformed entries are skipped. */
141
+ function toCosts(raw: unknown): CatalogCost[] | undefined {
142
+ if (!Array.isArray(raw)) return undefined
143
+ const costs: CatalogCost[] = []
144
+ for (const item of raw) {
145
+ const cost = record(item)
146
+ const input = finite(cost?.input)
147
+ const output = finite(cost?.output)
148
+ if (!cost || input === undefined || output === undefined) continue
149
+ const cache = record(cost.cache)
150
+ const tier = record(cost.tier)
151
+ const size = tier?.type === "context" ? finite(tier.size) : undefined
152
+ costs.push({
153
+ input,
154
+ output,
155
+ cacheRead: finite(cache?.read) ?? 0,
156
+ cacheWrite: finite(cache?.write) ?? 0,
157
+ ...(size === undefined ? {} : { aboveContext: size }),
158
+ })
159
+ }
160
+ return costs
161
+ }
162
+
135
163
  /**
136
164
  * Reduces `ctx.model.list` data (V2 `Model.Info`, tolerant of missing fields) to the catalog.
137
165
  * Models with `enabled: false` are dropped; `modelID` wins over `id`; variants may be `{ id }` or strings.
166
+ * Cost, limits, modalities, status, family and release date are carried as the host reports them
167
+ * (`Model.Info`, packages/schema/src/model.ts); anything absent or malformed is left out.
138
168
  */
139
169
  function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
140
170
  const catalog: CatalogModel[] = []
@@ -149,7 +179,13 @@ function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
149
179
  .filter((v): v is string => typeof v === "string" && v.length > 0)
150
180
  const caps = record(model.capabilities)
151
181
  const input = caps && Array.isArray(caps.input) ? caps.input : undefined
152
- const context = record(model.limit)?.context
182
+ const limit = record(model.limit)
183
+ const context = limit?.context
184
+ const limits = { context: finite(limit?.context), input: finite(limit?.input), output: finite(limit?.output) }
185
+ const modalityIn = strings(caps?.input)
186
+ const modalityOut = strings(caps?.output)
187
+ const cost = toCosts(model.cost)
188
+ const released = finite(record(model.time)?.released)
153
189
  catalog.push({
154
190
  providerID,
155
191
  id,
@@ -164,6 +200,14 @@ function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
164
200
  }
165
201
  : {}),
166
202
  ...(typeof context === "number" ? { contextLimit: context } : {}),
203
+ ...(limits.context !== undefined || limits.input !== undefined || limits.output !== undefined
204
+ ? { limits: Object.fromEntries(Object.entries(limits).filter(([, value]) => value !== undefined)) }
205
+ : {}),
206
+ ...(modalityIn || modalityOut ? { modalities: { input: modalityIn ?? [], output: modalityOut ?? [] } } : {}),
207
+ ...(cost && cost.length > 0 ? { cost } : {}),
208
+ ...(typeof model.status === "string" && STATUSES.has(model.status) ? { status: model.status as NonNullable<CatalogModel["status"]> } : {}),
209
+ ...(typeof model.family === "string" && model.family ? { family: model.family } : {}),
210
+ ...(released !== undefined ? { released } : {}),
167
211
  })
168
212
  }
169
213
  return catalog
@@ -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
- async function toDataUrl(url: string, signal: AbortSignal, remoteImages: boolean, budget: Budget): Promise<string> {
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 (!remoteImages)
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
- // Messages never carry the URL or the fetch error text: both can hold credentials in the query string.
40
- const response = await fetch(url, { redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]) }).catch((error: unknown) => {
41
- const reason = error instanceof Error && error.name === "TimeoutError" ? "timed out" : "network error"
42
- throw new ImageError("image_fetch_failed", `Could not fetch image (${reason})`)
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
- if (!response.ok) throw new ImageError("image_fetch_failed", `Could not fetch image (HTTP ${response.status})`)
45
- const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? ""
46
- if (!mediaType.startsWith("image/")) throw new ImageError("invalid_image", `URL did not return an image (content-type: ${mediaType || "none"})`)
47
- const declared = Number(response.headers.get("content-length"))
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) throw tooLarge()
53
- const chunks: Uint8Array[] = []
284
+ if (Number.isFinite(declared) && declared > limit) {
285
+ response.destroy()
286
+ throw tooLarge()
287
+ }
288
+ const chunks: Buffer[] = []
54
289
  let size = 0
55
- const reader = response.body?.getReader()
56
- if (!reader) throw new ImageError("invalid_image", "Image response had no body")
57
- while (true) {
58
- const { done, value } = await reader.read()
59
- if (done) break
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
- chunks.push(value)
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: boolean,
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 }
@@ -57,6 +57,8 @@ export interface SessionStore {
57
57
  retire(sessionID: string, retirement: Retirement): void
58
58
  /** Fail every registered request with `error`; returns how many were registered. */
59
59
  failAll(error: PlugError): number
60
+ /** How many request guards are outstanding (metrics and status only). */
61
+ size(): number
60
62
  }
61
63
 
62
64
  const EXECUTION_END = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
@@ -140,5 +142,6 @@ export function createSessionStore(options: SessionStoreOptions = {}): SessionSt
140
142
  }
141
143
  return entries.size
142
144
  },
145
+ size: () => entries.size,
143
146
  }
144
147
  }