opencode-plugboard 0.5.0 → 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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
30
30
  {
31
31
  "plugins": [
32
32
  {
33
- "package": "opencode-plugboard@0.5.0",
33
+ "package": "opencode-plugboard@0.6.0",
34
34
  "options": {
35
35
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
36
36
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -81,10 +81,9 @@ Plugboard is early (0.x) and narrow on purpose.
81
81
  - **No concurrency limit per app** yet ([#54](https://github.com/jjjermiah/opencode-plugboard/issues/54), [#86](https://github.com/jjjermiah/opencode-plugboard/issues/86)).
82
82
  - **Not a gateway with its own provider keys.** It only reuses what OpenCode is logged in to.
83
83
 
84
- How it differs from the alternatives:
84
+ How it differs from a standalone gateway:
85
85
 
86
- - [KochC/opencode-llm-proxy](https://github.com/KochC/opencode-llm-proxy) serves more API formats but targets OpenCode V1 only. Plugboard is V2 only and adds per-app keys and history, an explicit model allowlist, and rules that keep it from freezing the host (see [AGENTS.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/AGENTS.md)).
87
- - [LiteLLM](https://github.com/BerriAI/litellm) is a separate gateway you run and give its own provider keys. It covers many more providers and API shapes. Pick it when you want a standalone service. Pick plugboard when OpenCode already holds the logins you want to reuse, such as a ChatGPT or Copilot subscription.
86
+ - [LiteLLM](https://github.com/BerriAI/litellm) is a separate gateway you run and give its own provider keys. It covers many more providers and API shapes. Pick it when you want a standalone service. Pick plugboard when OpenCode already holds the logins you want to reuse, such as a ChatGPT or Copilot subscription. Plugboard also gives each app its own key and history, an explicit model allowlist, and rules that keep it from ever freezing the host.
88
87
 
89
88
  The most common first failure is an SDK defaulting to `/v1/responses` for newer models. Plugboard serves it, but some clients need `store: false` or a "use chat completions" switch. Check the client's page below.
90
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
2
2
 
3
3
  import type { PlugError } from "./contract.js"
4
+ import { parseAllowEntry, type RemoteImages } from "./session/media.js"
4
5
  import type { HistoryLimits } from "./session/prune.js"
5
6
 
6
7
  /** Plugin options, after defaults and validation (see `parseOptions`). */
@@ -14,8 +15,13 @@ export interface PlugboardOptions {
14
15
  readonly aliases: Readonly<Record<string, string>>
15
16
  readonly maxTools: number
16
17
  readonly requestTimeoutMs: number
17
- /** Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network. */
18
- readonly remoteImages: boolean
18
+ /**
19
+ * Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network.
20
+ * `true` fetches public addresses only (loopback, private, link-local, CGNAT and ULA ranges are refused);
21
+ * `{ allow }` is on too, and lists the hosts (`name`, `*.suffix`), addresses or CIDR ranges that may be
22
+ * fetched even when they are not public.
23
+ */
24
+ readonly remoteImages: RemoteImages
19
25
  /** Prune old request sessions (see `src/session/prune.ts`). A limit that is unset is not applied; with neither, history is kept forever. Applies per app. */
20
26
  readonly history: HistoryLimits
21
27
  /** Drop sampling parameters the target model is known to reject (reported in `x-plugboard-dropped-params`). On by default; off forwards them as sent. */
@@ -68,6 +74,23 @@ function positiveInt(value: unknown, name: string, fallback: number, max = Numbe
68
74
  return value
69
75
  }
70
76
 
77
+ const REMOTE_IMAGES_ERROR =
78
+ "plugboard option `remoteImages` must be a boolean or `{ allow: [...] }` with host names, IP addresses or CIDR ranges that may be fetched even when they are not public"
79
+
80
+ function parseRemoteImages(value: unknown): RemoteImages {
81
+ if (value === undefined) return false
82
+ if (typeof value === "boolean") return value
83
+ if (!isRecord(value)) throw new Error(REMOTE_IMAGES_ERROR)
84
+ for (const key of Object.keys(value))
85
+ if (key !== "allow") throw new Error(`Unknown plugboard option \`remoteImages.${key}\` (known: allow)`)
86
+ const allow = value.allow ?? []
87
+ if (!Array.isArray(allow)) throw new Error(REMOTE_IMAGES_ERROR)
88
+ for (const entry of allow)
89
+ if (typeof entry !== "string" || parseAllowEntry(entry) === undefined)
90
+ throw new Error(`plugboard \`remoteImages.allow\` entry \`${String(entry)}\` must be a host name (\`images.internal\`, \`*.internal\`), an IP address or a CIDR range`)
91
+ return { allow: allow as string[] }
92
+ }
93
+
71
94
  /** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
72
95
  export function parseOptions(raw: unknown): PlugboardOptions {
73
96
  if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
@@ -122,8 +145,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
122
145
  }
123
146
  const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
124
147
 
125
- if (input.remoteImages !== undefined && typeof input.remoteImages !== "boolean")
126
- throw new Error("plugboard option `remoteImages` must be a boolean")
148
+ const remoteImages = parseRemoteImages(input.remoteImages)
127
149
  if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
128
150
  throw new Error("plugboard option `dropUnsupportedParams` must be a boolean")
129
151
 
@@ -145,7 +167,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
145
167
  aliases,
146
168
  maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
147
169
  requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
148
- remoteImages: input.remoteImages === true,
170
+ remoteImages,
149
171
  history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
150
172
  dropUnsupportedParams: input.dropUnsupportedParams !== false,
151
173
  logFormat: input.logFormat === "json" ? "json" : "text",
@@ -157,6 +157,11 @@ export function createOpenAiRoute(routeHost: RouteHost) {
157
157
  return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
158
158
  }
159
159
 
160
+ // Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
161
+ const auth = authenticate(host.options, request.headers.authorization)
162
+ if (isPlugError(auth)) return fail(response, auth)
163
+ info.app = auth.app
164
+
160
165
  if (modelId === undefined && !known(route)) {
161
166
  return fail(response, {
162
167
  status: 404,
@@ -166,10 +171,6 @@ export function createOpenAiRoute(routeHost: RouteHost) {
166
171
  })
167
172
  }
168
173
 
169
- const auth = authenticate(host.options, request.headers.authorization)
170
- if (isPlugError(auth)) return fail(response, auth)
171
- info.app = auth.app
172
-
173
174
  if (route === "GET /v1/status") {
174
175
  const catalog = await optionalCatalog(scope)
175
176
  return sendJson(response, 200, buildStatus({ options: host.options, app: auth.app, runtime: runtimeStatus(), catalog }))
package/src/runtime.ts CHANGED
@@ -100,8 +100,12 @@ const SWEEP_DEADLINE_MS = 60_000
100
100
  const BACKEND_TIMEOUT_MS = 15_000
101
101
  const BACKEND_POLL_MS = 25
102
102
 
103
- /** 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
104
- const VERSION = 5
103
+ /** How often a listener that could not bind tries again, and how often it repeats the error while it still cannot (#87). */
104
+ const BIND_RETRY_MS = 15_000
105
+ const BIND_REMINDER_MS = 5 * 60_000
106
+
107
+ /** 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
108
+ const VERSION = 6
105
109
  const KEY = Symbol.for("opencode-plugboard.runtime")
106
110
 
107
111
  interface Listener {
@@ -111,6 +115,11 @@ interface Listener {
111
115
  fingerprint: string
112
116
  mismatchLogged: boolean
113
117
  bound: boolean
118
+ /** Failed bind attempts since the last success: while this is above 0 nothing receives requests on the port. */
119
+ bindFailures: number
120
+ /** When the bind failure was last logged (epoch ms). */
121
+ bindLoggedAt: number
122
+ retryTimer?: ReturnType<typeof setTimeout>
114
123
  graceTimer?: ReturnType<typeof setTimeout>
115
124
  }
116
125
 
@@ -312,7 +321,7 @@ const unavailable = (response: ServerResponse, message: string) => {
312
321
  }
313
322
 
314
323
  function startListener(rt: Runtime, listen: { host: string; port: number }, fingerprint: string): Listener {
315
- const listener: Listener = { server: createServer(), listen, fingerprint, mismatchLogged: false, bound: false }
324
+ const listener: Listener = { server: createServer(), listen, fingerprint, mismatchLogged: false, bound: false, bindFailures: 0, bindLoggedAt: 0 }
316
325
  listener.server.on("request", (request, response) => {
317
326
  const current = preferred(rt)
318
327
  if (!current) return unavailable(response, "plugboard is reloading")
@@ -325,22 +334,39 @@ function startListener(rt: Runtime, listen: { host: string; port: number }, fing
325
334
  })
326
335
  listener.server.on("error", (error: NodeJS.ErrnoException) => {
327
336
  if (listener.bound) return logError("listener.error", { code: error.code, error: error.message })
328
- // Never bound: another process (or an older runtime) owns the port. Log once, stay inert.
329
- logError(error.code === "EADDRINUSE" ? "listener.address_in_use" : "listener.failed", {
330
- listen: `${listen.host}:${listen.port}`,
331
- error: error.message,
332
- })
337
+ // Not bound: another process (or an older runtime) owns the port, so nothing here receives
338
+ // requests while clients may be sending their keys and prompts to whoever holds it. Say so
339
+ // loudly (error level, again every few minutes), keep trying, and mention it on every
340
+ // generation that registers meanwhile (`registerGeneration`).
341
+ listener.bindFailures++
342
+ if (Date.now() - listener.bindLoggedAt >= BIND_REMINDER_MS) {
343
+ listener.bindLoggedAt = Date.now()
344
+ logError(error.code === "EADDRINUSE" ? "listener.address_in_use" : "listener.failed", {
345
+ listen: `${listen.host}:${listen.port}`,
346
+ error: error.message,
347
+ status: "not serving: clients of this port reach another process",
348
+ action: "stop the process that holds the port or change `listen`; plugboard retries every 15s",
349
+ })
350
+ }
351
+ listener.retryTimer = setTimeout(() => {
352
+ listener.retryTimer = undefined
353
+ if (rt.listener === listener && !listener.bound) listener.server.listen(listen.port, listen.host)
354
+ }, BIND_RETRY_MS)
355
+ listener.retryTimer.unref?.()
333
356
  })
334
- listener.server.listen(listen.port, listen.host, () => {
357
+ listener.server.on("listening", () => {
335
358
  listener.bound = true
336
- log("listener.listening", { listen: `${listen.host}:${listen.port}` })
359
+ log("listener.listening", { listen: `${listen.host}:${listen.port}`, ...(listener.bindFailures > 0 ? { recoveredAfterFailedBinds: listener.bindFailures } : {}) })
360
+ listener.bindFailures = 0
337
361
  })
362
+ listener.server.listen(listen.port, listen.host)
338
363
  return listener
339
364
  }
340
365
 
341
366
  function closeListener(rt: Runtime, listener: Listener): void {
342
367
  if (rt.listener !== listener) return
343
368
  rt.listener = undefined
369
+ clearTimeout(listener.retryTimer)
344
370
  try {
345
371
  rt.pump?.stop()
346
372
  } catch {}
@@ -404,6 +430,13 @@ export function registerGeneration(input: GenerationInput): Registration {
404
430
  }
405
431
  if (listener.graceTimer) clearTimeout(listener.graceTimer)
406
432
  listener.graceTimer = undefined
433
+ if (listener.bindFailures > 0) {
434
+ logError("listener.not_serving", {
435
+ listen: `${listen.host}:${listen.port}`,
436
+ status: "another process holds the port; this generation serves nothing",
437
+ action: "stop that process or change `listen`",
438
+ })
439
+ }
407
440
  const generation: Generation = {
408
441
  directory,
409
442
  handler: input.handler,
@@ -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
  }
@@ -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 }