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.
- package/README.md +3 -4
- package/package.json +3 -3
- package/src/config.ts +38 -6
- package/src/contract.ts +21 -0
- package/src/discovery.ts +280 -0
- package/src/log.ts +56 -5
- package/src/metrics.ts +176 -0
- package/src/openai/response.ts +29 -1
- package/src/openai/route.ts +100 -12
- package/src/runtime.ts +103 -18
- package/src/session/caller.ts +5 -5
- package/src/session/events.ts +60 -12
- package/src/session/history.ts +18 -6
- package/src/session/host.ts +46 -2
- package/src/session/media.ts +256 -23
- package/src/session/state.ts +3 -0
package/src/session/caller.ts
CHANGED
|
@@ -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?:
|
|
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
|
|
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 } : {}) },
|
package/src/session/events.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
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.
|
|
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 })
|
package/src/session/history.ts
CHANGED
|
@@ -50,9 +50,20 @@ const firstUserText = (request: PlugRequest): string => {
|
|
|
50
50
|
return request.model
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/src/session/host.ts
CHANGED
|
@@ -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
|
|
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
|
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 }
|
package/src/session/state.ts
CHANGED
|
@@ -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
|
}
|