Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content. Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation. Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit. Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
1074 lines
30 KiB
TypeScript
1074 lines
30 KiB
TypeScript
import { runtimePrivacyEnvironment } from '../shared/runtime-privacy-environment'
|
|
import { randomBytes, randomUUID } from 'node:crypto'
|
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
|
import {
|
|
createServer,
|
|
type IncomingHttpHeaders,
|
|
type IncomingMessage,
|
|
type Server,
|
|
type ServerResponse
|
|
} from 'node:http'
|
|
import type { Socket } from 'node:net'
|
|
import { isAbsolute, resolve, join } from 'node:path'
|
|
import type {
|
|
ModelBridgeModelProtocol
|
|
} from '../shared/model-bridge-contracts'
|
|
import {
|
|
REMOTE_MODEL_GATEWAY_LIMITS,
|
|
remoteModelApiPathSchema,
|
|
remoteModelGatewayRequestSchema,
|
|
remoteModelGatewayResponseSchema,
|
|
type RemoteModelGatewayRequest
|
|
} from '../shared/remote-model-gateway-contracts'
|
|
import {
|
|
createUnixModelBridgeExchange,
|
|
ModelBridgeBrokerError,
|
|
type ModelBridgeExchange,
|
|
type ModelBridgeExchangeResult
|
|
} from './model-bridge-broker'
|
|
import {
|
|
boundedInteger,
|
|
closeServer,
|
|
raceWithAbort,
|
|
settleWithin
|
|
} from './async-utils'
|
|
|
|
export type ModelBridgeProtocol = ModelBridgeModelProtocol
|
|
|
|
// The Anthropic and OpenAI SDK adapters refuse to issue a request without an
|
|
// apiKey option. This fixed value is only a local compatibility marker. The
|
|
// loopback proxy verifies and removes its header before creating the wire
|
|
// request. A separate per-helper random route capability authenticates local
|
|
// requests, so this marker is neither a credential nor visible upstream.
|
|
export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
|
|
'goodbuddy-local-model-bridge-v1'
|
|
|
|
export type OpenCodeModelBridgeProviderConfig = {
|
|
model: string
|
|
snapshot: false
|
|
permission: 'ask' | 'allow'
|
|
agent: {
|
|
title: {
|
|
disable: true
|
|
}
|
|
build: {
|
|
permission: 'ask' | 'allow'
|
|
}
|
|
}
|
|
provider: Record<
|
|
string,
|
|
{
|
|
name: string
|
|
npm:
|
|
| '@ai-sdk/anthropic'
|
|
| '@ai-sdk/openai-compatible'
|
|
| '@ai-sdk/openai'
|
|
options: {
|
|
baseURL: string
|
|
apiKey?: typeof MODEL_BRIDGE_SDK_AUTH_SENTINEL
|
|
}
|
|
models: Record<
|
|
string,
|
|
{
|
|
name: string
|
|
attachment: boolean
|
|
modalities: {
|
|
input: Array<'text' | 'image'>
|
|
output: ['text']
|
|
}
|
|
provider: {
|
|
npm:
|
|
| '@ai-sdk/anthropic'
|
|
| '@ai-sdk/openai-compatible'
|
|
| '@ai-sdk/openai'
|
|
}
|
|
}
|
|
>
|
|
}
|
|
>
|
|
}
|
|
|
|
export type ModelBridgeHelperChild = {
|
|
once(event: 'error', listener: (error: Error) => void): unknown
|
|
once(
|
|
event: 'close',
|
|
listener: (code: number | null, signal: NodeJS.Signals | null) => void
|
|
): unknown
|
|
}
|
|
|
|
export type ModelBridgeHelperSpawn = (
|
|
executable: string,
|
|
args: readonly string[],
|
|
options: {
|
|
shell: false
|
|
stdio: 'inherit'
|
|
env: Readonly<NodeJS.ProcessEnv>
|
|
}
|
|
) => ModelBridgeHelperChild
|
|
|
|
const LOOPBACK_HOST = '127.0.0.1'
|
|
const DEFAULT_MAXIMUM_CONNECTIONS = 8
|
|
const DEFAULT_REQUEST_TIMEOUT_MS = 0
|
|
const RESPONSE_CLOSE_TIMEOUT_MS = 2_000
|
|
const MAXIMUM_HTTP_HEADER_BYTES = 16 * 1024
|
|
const MAXIMUM_HTTP_HEADER_COUNT = 32
|
|
const MODEL_BRIDGE_ROUTE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u
|
|
const CREDENTIAL_INBOUND_HEADER_NAMES = new Set([
|
|
'api-key',
|
|
'authorization',
|
|
'cookie',
|
|
'proxy-authorization',
|
|
'set-cookie',
|
|
'x-api-key',
|
|
'x-goog-api-key'
|
|
])
|
|
const FORWARDED_HEADER_NAMES = [
|
|
'accept',
|
|
'content-type'
|
|
] as const
|
|
|
|
export class ModelBridgeLoopbackProxy {
|
|
readonly #exchange: ModelBridgeExchange
|
|
readonly #sharedSessions: boolean
|
|
readonly #sessionRoutes = new Map<string, { operationId: string; socketPath: string; imageToolName?: string }>()
|
|
readonly #routeToken: string
|
|
readonly #maximumConnections: number
|
|
readonly #requestTimeoutMs: number
|
|
readonly #sockets = new Set<Socket>()
|
|
readonly #dispatches = new Map<string, DispatchWaiter[]>()
|
|
#server?: Server
|
|
#origin?: string
|
|
#closing = false
|
|
#closePromise?: Promise<void>
|
|
|
|
constructor(options: {
|
|
exchange: ModelBridgeExchange
|
|
sharedSessions?: boolean
|
|
routeToken?: string
|
|
maximumConnections?: number
|
|
requestTimeoutMs?: number
|
|
}) {
|
|
this.#exchange = options.exchange
|
|
this.#sharedSessions = options.sharedSessions === true
|
|
this.#routeToken = parseModelBridgeRouteToken(
|
|
options.routeToken ??
|
|
randomBytes(32).toString('base64url')
|
|
)
|
|
this.#maximumConnections = boundedInteger(
|
|
options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS,
|
|
1,
|
|
64,
|
|
'Model bridge loopback connection limit'
|
|
)
|
|
this.#requestTimeoutMs = boundedInteger(
|
|
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
0,
|
|
0x7fff_ffff,
|
|
'Model bridge loopback request timeout'
|
|
)
|
|
}
|
|
|
|
get origin(): string {
|
|
if (this.#origin === undefined) {
|
|
throw new Error('Model bridge loopback proxy is not listening')
|
|
}
|
|
return this.#origin
|
|
}
|
|
|
|
async listen(): Promise<string> {
|
|
if (this.#server !== undefined || this.#closing) {
|
|
throw new Error('Model bridge loopback proxy is already active')
|
|
}
|
|
const server = createServer(
|
|
{
|
|
maxHeaderSize: MAXIMUM_HTTP_HEADER_BYTES,
|
|
requireHostHeader: true
|
|
},
|
|
(request, response) => {
|
|
void this.#handleRequest(request, response)
|
|
}
|
|
)
|
|
server.maxConnections = this.#maximumConnections
|
|
server.maxHeadersCount = MAXIMUM_HTTP_HEADER_COUNT
|
|
server.maxRequestsPerSocket = 1
|
|
server.requestTimeout = this.#requestTimeoutMs
|
|
server.headersTimeout = Math.min(
|
|
this.#requestTimeoutMs,
|
|
30_000
|
|
)
|
|
server.keepAliveTimeout = 1_000
|
|
server.on('connection', (socket) => {
|
|
if (
|
|
this.#closing ||
|
|
this.#sockets.size >= this.#maximumConnections
|
|
) {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
this.#sockets.add(socket)
|
|
socket.once('close', () => this.#sockets.delete(socket))
|
|
})
|
|
this.#server = server
|
|
try {
|
|
await listenOnLoopback(server)
|
|
const address = server.address()
|
|
if (address === null || typeof address === 'string') {
|
|
throw new Error(
|
|
'Model bridge loopback address is unavailable'
|
|
)
|
|
}
|
|
this.#origin =
|
|
`http://${LOOPBACK_HOST}:${address.port}/${this.#routeToken}`
|
|
return this.#origin
|
|
} catch (error) {
|
|
await this.close()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
if (this.#closePromise !== undefined) {
|
|
await this.#closePromise
|
|
return
|
|
}
|
|
const closePromise = this.#close()
|
|
this.#closePromise = closePromise
|
|
try {
|
|
await closePromise
|
|
} finally {
|
|
if (this.#closePromise === closePromise) {
|
|
this.#closePromise = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
async #close(): Promise<void> {
|
|
const server = this.#server
|
|
this.#server = undefined
|
|
this.#origin = undefined
|
|
this.#closing = true
|
|
try {
|
|
for (const socket of this.#sockets) {
|
|
socket.destroy()
|
|
}
|
|
await settleWithin(
|
|
server === undefined
|
|
? Promise.resolve()
|
|
: closeServer(server),
|
|
RESPONSE_CLOSE_TIMEOUT_MS
|
|
)
|
|
} finally {
|
|
this.#sockets.clear()
|
|
this.#sessionRoutes.clear()
|
|
this.#closing = false
|
|
}
|
|
}
|
|
|
|
async #handleRequest(
|
|
incoming: IncomingMessage,
|
|
outgoing: ServerResponse
|
|
): Promise<void> {
|
|
outgoing.setHeader('connection', 'close')
|
|
outgoing.setHeader('cache-control', 'no-store')
|
|
outgoing.setHeader('x-content-type-options', 'nosniff')
|
|
if (this.#closing) {
|
|
incoming.resume()
|
|
sendError(outgoing, 503, 'bridge-closing')
|
|
return
|
|
}
|
|
const cancellation = new AbortController()
|
|
let dispatched: ModelBridgeExchangeResult | undefined
|
|
let responseFlushed = false
|
|
let deliveryAcknowledged = false
|
|
let dispatchHeld = false
|
|
let dispatchKey = ''
|
|
let sessionId: string | undefined
|
|
let sessionRoute: { operationId: string; socketPath: string } | undefined
|
|
const onAborted = (): void => {
|
|
cancellation.abort(
|
|
new ModelBridgeBrokerError('request-cancelled')
|
|
)
|
|
}
|
|
const onResponseClose = (): void => {
|
|
if (!responseFlushed && !outgoing.writableFinished) {
|
|
onAborted()
|
|
void dispatched?.failDelivery(
|
|
new ModelBridgeBrokerError('transport-failed')
|
|
)
|
|
}
|
|
}
|
|
incoming.once('aborted', onAborted)
|
|
outgoing.once('close', onResponseClose)
|
|
const timeout = this.#requestTimeoutMs === 0 ? undefined : setTimeout(() => {
|
|
cancellation.abort(
|
|
new ModelBridgeBrokerError('request-timeout')
|
|
)
|
|
}, this.#requestTimeoutMs)
|
|
timeout?.unref?.()
|
|
|
|
try {
|
|
if (this.#sharedSessions && incoming.url?.startsWith(`/${this.#routeToken}/session`)) {
|
|
const url = new URL(incoming.url, 'http://localhost')
|
|
if (url.pathname !== `/${this.#routeToken}/session`) throw new HttpRequestError(404, 'path-not-allowed')
|
|
if (incoming.method === 'GET') {
|
|
const route = this.#sessionRoutes.get(url.searchParams.get('sessionId') ?? '')
|
|
if (!route) throw new HttpRequestError(409, 'session-inactive')
|
|
outgoing.setHeader('content-type', 'application/json')
|
|
outgoing.end(JSON.stringify({ operationId: route.operationId, imageToolName: route.imageToolName }))
|
|
return
|
|
}
|
|
if (incoming.method !== 'POST') throw new HttpRequestError(405, 'method-not-allowed')
|
|
const value = JSON.parse((await readBoundedBody(incoming)).toString('utf8')) as {
|
|
sessionId?: string; operationId?: string; socketPath?: string; release?: boolean; imageToolName?: string
|
|
}
|
|
if (
|
|
typeof value.sessionId !== 'string' || value.sessionId.length > 128 ||
|
|
typeof value.operationId !== 'string' || value.operationId.length > 128
|
|
) throw new HttpRequestError(400, 'request-invalid')
|
|
if (value.release) {
|
|
if (this.#sessionRoutes.get(value.sessionId)?.operationId === value.operationId) {
|
|
this.#sessionRoutes.delete(value.sessionId)
|
|
}
|
|
} else {
|
|
const socketPath = normalizedAbsolutePath(value.socketPath ?? '', 'Model bridge socket')
|
|
if (value.imageToolName !== undefined && !/^goodbuddy_image_[a-f0-9]{24}$/.test(value.imageToolName)) throw new HttpRequestError(400, 'request-invalid')
|
|
this.#sessionRoutes.set(value.sessionId, { operationId: value.operationId, socketPath, imageToolName: value.imageToolName })
|
|
}
|
|
outgoing.end('{}')
|
|
return
|
|
}
|
|
const request = await parseHttpRequest(
|
|
incoming,
|
|
this.#routeToken
|
|
)
|
|
let exchange = this.#exchange
|
|
if (this.#sharedSessions) {
|
|
const sessionHeader = incoming.headers['x-goodbuddy-session']
|
|
const operationId = incoming.headers['x-goodbuddy-operation']
|
|
sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined
|
|
const route = sessionId ? this.#sessionRoutes.get(sessionId) : undefined
|
|
if (!route || route.operationId !== operationId) throw new HttpRequestError(409, 'session-inactive')
|
|
sessionRoute = route
|
|
dispatchKey = `${sessionId}\0${operationId}`
|
|
exchange = createUnixModelBridgeExchange({ socketPath: route.socketPath })
|
|
}
|
|
await this.#acquireDispatch(dispatchKey, cancellation.signal)
|
|
dispatchHeld = true
|
|
if (sessionId && this.#sessionRoutes.get(sessionId) !== sessionRoute) {
|
|
throw new HttpRequestError(409, 'session-inactive')
|
|
}
|
|
dispatched = normalizeExchangeResult(
|
|
await raceWithAbort(exchange(request, {
|
|
requestId: `model-${randomUUID()}`,
|
|
signal: cancellation.signal
|
|
}), cancellation.signal)
|
|
)
|
|
const parsedResponse =
|
|
remoteModelGatewayResponseSchema.parse(dispatched.response)
|
|
if (!outgoing.headersSent && !outgoing.destroyed) {
|
|
outgoing.statusCode = parsedResponse.status
|
|
for (const [name, value] of Object.entries(
|
|
parsedResponse.headers
|
|
)) {
|
|
outgoing.setHeader(name, value)
|
|
}
|
|
const body = Buffer.from(
|
|
parsedResponse.bodyBase64,
|
|
'base64'
|
|
)
|
|
outgoing.setHeader('content-length', body.byteLength)
|
|
await endResponseFlushed(outgoing, body, () => {
|
|
responseFlushed = true
|
|
})
|
|
cancellation.signal.throwIfAborted()
|
|
await dispatched.acknowledgeDelivery()
|
|
deliveryAcknowledged = true
|
|
}
|
|
} catch (error) {
|
|
if (dispatched !== undefined && !deliveryAcknowledged) {
|
|
await Promise.resolve(
|
|
dispatched.failDelivery(error)
|
|
).catch(() => undefined)
|
|
}
|
|
if (!outgoing.headersSent && !outgoing.destroyed) {
|
|
const failure = httpFailure(error, cancellation.signal)
|
|
sendError(outgoing, failure.status, failure.code)
|
|
}
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
incoming.off('aborted', onAborted)
|
|
outgoing.off('close', onResponseClose)
|
|
if (dispatchHeld) {
|
|
this.#releaseDispatch(dispatchKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
async #acquireDispatch(key: string, signal: AbortSignal): Promise<void> {
|
|
signal.throwIfAborted()
|
|
const queue = this.#dispatches.get(key)
|
|
if (!queue) {
|
|
this.#dispatches.set(key, [])
|
|
return
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
const waiter: DispatchWaiter = { resolve, reject, signal }
|
|
waiter.abort = (): void => {
|
|
const index = queue.indexOf(waiter)
|
|
if (index >= 0) {
|
|
queue.splice(index, 1)
|
|
}
|
|
reject(signal.reason)
|
|
}
|
|
signal.addEventListener('abort', waiter.abort, { once: true })
|
|
queue.push(waiter)
|
|
})
|
|
}
|
|
|
|
#releaseDispatch(key: string): void {
|
|
const queue = this.#dispatches.get(key)
|
|
while (queue && queue.length > 0) {
|
|
const waiter = queue.shift()!
|
|
if (waiter.abort !== undefined) {
|
|
waiter.signal.removeEventListener('abort', waiter.abort)
|
|
}
|
|
if (waiter.signal.aborted) {
|
|
waiter.reject(waiter.signal.reason)
|
|
continue
|
|
}
|
|
waiter.resolve()
|
|
return
|
|
}
|
|
this.#dispatches.delete(key)
|
|
}
|
|
}
|
|
|
|
export async function runOpenCodeModelBridgeHelper(options: {
|
|
socketPath: string
|
|
protocol: ModelBridgeProtocol
|
|
model: string
|
|
supportsImageInput: boolean
|
|
sharedSessions?: boolean
|
|
opencodeEntrypoint: string
|
|
environment?: Readonly<NodeJS.ProcessEnv>
|
|
spawn?: ModelBridgeHelperSpawn
|
|
}): Promise<number> {
|
|
const socketPath = normalizedAbsolutePath(
|
|
options.socketPath,
|
|
'Model bridge socket'
|
|
)
|
|
const opencodeEntrypoint = normalizedAbsolutePath(
|
|
options.opencodeEntrypoint,
|
|
'OpenCode entrypoint'
|
|
)
|
|
const exchange = createUnixModelBridgeExchange({ socketPath })
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange, sharedSessions: options.sharedSessions })
|
|
const origin = await proxy.listen()
|
|
let pluginDirectory: string | undefined
|
|
try {
|
|
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
|
|
const pluginPath = join(pluginDirectory, 'subagent.mjs')
|
|
await writeFile(pluginPath, openCodeSubagentPluginSource(options.sharedSessions ? origin : undefined), 'utf8')
|
|
const config = createOpenCodeModelBridgeProviderConfig({
|
|
protocol: options.protocol,
|
|
model: options.model,
|
|
loopbackOrigin: origin,
|
|
supportsImageInput: options.supportsImageInput
|
|
})
|
|
const environment = credentialFreeHelperEnvironment(
|
|
options.environment ?? process.env,
|
|
JSON.stringify({
|
|
...config,
|
|
plugin: [pathToFileURL(pluginPath).href]
|
|
})
|
|
)
|
|
if (options.sharedSessions) {
|
|
process.stdout.write(JSON.stringify({
|
|
jsonrpc: '2.0', method: 'goodbuddy/modelBridgeReady', params: { origin }
|
|
}) + '\n')
|
|
}
|
|
const child = (options.spawn ?? defaultHelperSpawn)(
|
|
opencodeEntrypoint,
|
|
['acp'],
|
|
{
|
|
shell: false,
|
|
stdio: 'inherit',
|
|
env: environment
|
|
}
|
|
)
|
|
return await new Promise<number>((resolveExit, reject) => {
|
|
child.once('error', reject)
|
|
child.once('close', (code, signal) => {
|
|
if (signal !== null) {
|
|
resolveExit(128)
|
|
return
|
|
}
|
|
resolveExit(code ?? 1)
|
|
})
|
|
})
|
|
} finally {
|
|
await proxy.close()
|
|
if (pluginDirectory) {
|
|
await rm(pluginDirectory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
function endResponseFlushed(
|
|
response: ServerResponse,
|
|
body: Uint8Array,
|
|
onFlushed: () => void
|
|
): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const onFinish = (): void => {
|
|
cleanup()
|
|
onFlushed()
|
|
resolve()
|
|
}
|
|
const onClose = (): void => {
|
|
if (response.writableFinished) {
|
|
onFinish()
|
|
return
|
|
}
|
|
cleanup()
|
|
reject(new ModelBridgeBrokerError('transport-failed'))
|
|
}
|
|
const onError = (): void => {
|
|
cleanup()
|
|
reject(new ModelBridgeBrokerError('transport-failed'))
|
|
}
|
|
const cleanup = (): void => {
|
|
response.off('finish', onFinish)
|
|
response.off('close', onClose)
|
|
response.off('error', onError)
|
|
}
|
|
response.once('finish', onFinish)
|
|
response.once('close', onClose)
|
|
response.once('error', onError)
|
|
response.end(body)
|
|
})
|
|
}
|
|
|
|
type DispatchWaiter = {
|
|
resolve: () => void
|
|
reject: (error: unknown) => void
|
|
signal: AbortSignal
|
|
abort?: () => void
|
|
}
|
|
|
|
export function createOpenCodeModelBridgeProviderConfig(input: {
|
|
protocol: ModelBridgeProtocol
|
|
model: string
|
|
loopbackOrigin: string
|
|
name?: string
|
|
supportsImageInput?: boolean
|
|
}): OpenCodeModelBridgeProviderConfig {
|
|
const model = boundedMetadataText(input.model, 'Model name')
|
|
const name = boundedMetadataText(
|
|
input.name ?? 'GoodBuddy Model Bridge',
|
|
'Provider name'
|
|
)
|
|
const origin = parseLoopbackOrigin(input.loopbackOrigin)
|
|
const descriptor = providerDescriptor(input.protocol)
|
|
const npm = descriptor.npm
|
|
const providerId = descriptor.id
|
|
const baseURL = `${origin}/v1`
|
|
return {
|
|
model: openCodeModelBridgeModelId(input.protocol, model),
|
|
snapshot: false,
|
|
permission: 'allow',
|
|
// GoodBuddy owns conversation titles. A first-prompt title request can
|
|
// race the real ACP prompt, abandon its Provider response, and correctly
|
|
// poison the no-replay ledger before the user request can run.
|
|
agent: {
|
|
title: {
|
|
disable: true
|
|
},
|
|
build: {
|
|
permission: 'allow'
|
|
}
|
|
},
|
|
provider: {
|
|
[providerId]: {
|
|
name,
|
|
npm,
|
|
options: {
|
|
baseURL,
|
|
...(descriptor.requiresSdkAuthSentinel
|
|
? { apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL }
|
|
: {})
|
|
},
|
|
models: {
|
|
[model]: {
|
|
name,
|
|
attachment: input.supportsImageInput === true,
|
|
modalities: {
|
|
input:
|
|
input.supportsImageInput === true
|
|
? ['text', 'image']
|
|
: ['text'],
|
|
output: ['text']
|
|
},
|
|
provider: { npm }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function openCodeModelBridgeModelId(
|
|
protocol: ModelBridgeProtocol,
|
|
modelInput: string
|
|
): string {
|
|
const model = boundedMetadataText(modelInput, 'Model name')
|
|
return `${providerDescriptor(protocol).id}/${model}`
|
|
}
|
|
|
|
async function parseHttpRequest(
|
|
incoming: IncomingMessage,
|
|
routeToken: string
|
|
): Promise<RemoteModelGatewayRequest> {
|
|
if (incoming.method !== 'POST') {
|
|
throw new HttpRequestError(405, 'method-not-allowed')
|
|
}
|
|
const path = remoteModelApiPathSchema.safeParse(
|
|
authenticatedApiPath(incoming.url, routeToken)
|
|
)
|
|
if (!path.success) {
|
|
throw new HttpRequestError(404, 'path-not-allowed')
|
|
}
|
|
validateInboundHeaders(
|
|
incoming.headers,
|
|
incoming.rawHeaders,
|
|
path.data
|
|
)
|
|
const declaredLength = parseContentLength(
|
|
incoming.headers['content-length']
|
|
)
|
|
if (declaredLength === undefined) {
|
|
throw new HttpRequestError(411, 'content-length-required')
|
|
}
|
|
if (
|
|
declaredLength !== undefined &&
|
|
declaredLength >
|
|
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
|
|
) {
|
|
incoming.resume()
|
|
throw new HttpRequestError(413, 'request-too-large')
|
|
}
|
|
const body = await readBoundedBody(incoming)
|
|
if (
|
|
declaredLength !== undefined &&
|
|
body.byteLength !== declaredLength
|
|
) {
|
|
throw new HttpRequestError(400, 'request-invalid')
|
|
}
|
|
const headers: Record<string, string> = {}
|
|
for (const name of FORWARDED_HEADER_NAMES) {
|
|
const value = incoming.headers[name]
|
|
if (typeof value === 'string') {
|
|
headers[name] = value
|
|
}
|
|
}
|
|
const parsed = remoteModelGatewayRequestSchema.safeParse({
|
|
method: 'POST',
|
|
path: path.data,
|
|
headers,
|
|
bodyBase64: body.toString('base64')
|
|
})
|
|
if (!parsed.success) {
|
|
throw new HttpRequestError(400, 'request-invalid')
|
|
}
|
|
return parsed.data
|
|
}
|
|
|
|
function validateInboundHeaders(
|
|
headers: IncomingHttpHeaders,
|
|
rawHeaders: readonly string[],
|
|
path: RemoteModelGatewayRequest['path']
|
|
): void {
|
|
const entries = Object.entries(headers)
|
|
if (entries.length > MAXIMUM_HTTP_HEADER_COUNT) {
|
|
throw new HttpRequestError(431, 'headers-rejected')
|
|
}
|
|
const securityCriticalNames = new Set([
|
|
'accept',
|
|
'connection',
|
|
'content-length',
|
|
'content-type',
|
|
'host',
|
|
'transfer-encoding',
|
|
...CREDENTIAL_INBOUND_HEADER_NAMES
|
|
])
|
|
const seen = new Set<string>()
|
|
for (let index = 0; index < rawHeaders.length; index += 2) {
|
|
const name = rawHeaders[index]?.toLowerCase()
|
|
if (
|
|
name !== undefined &&
|
|
securityCriticalNames.has(name) &&
|
|
seen.has(name)
|
|
) {
|
|
throw new HttpRequestError(400, 'headers-rejected')
|
|
}
|
|
if (name !== undefined) {
|
|
seen.add(name)
|
|
}
|
|
}
|
|
for (const [name, value] of entries) {
|
|
if (
|
|
!/^[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(name) ||
|
|
Array.isArray(value) ||
|
|
(CREDENTIAL_INBOUND_HEADER_NAMES.has(name) &&
|
|
!isExpectedSdkAuthenticationHeader(path, name, value))
|
|
) {
|
|
throw new HttpRequestError(400, 'headers-rejected')
|
|
}
|
|
if (
|
|
value !== undefined &&
|
|
(value.length >
|
|
REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes ||
|
|
/[\r\n]/u.test(value))
|
|
) {
|
|
throw new HttpRequestError(431, 'headers-rejected')
|
|
}
|
|
if (name === 'transfer-encoding' && value !== undefined) {
|
|
throw new HttpRequestError(400, 'headers-rejected')
|
|
}
|
|
}
|
|
}
|
|
|
|
function isExpectedSdkAuthenticationHeader(
|
|
path: RemoteModelGatewayRequest['path'],
|
|
name: string,
|
|
value: string | undefined
|
|
): boolean {
|
|
if (path === '/v1/messages') {
|
|
return (
|
|
name === 'x-api-key' &&
|
|
value === MODEL_BRIDGE_SDK_AUTH_SENTINEL
|
|
)
|
|
}
|
|
if (path === '/responses' || path === '/v1/responses') {
|
|
return (
|
|
name === 'authorization' &&
|
|
value === `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
|
|
)
|
|
}
|
|
if (
|
|
path === '/chat/completions' ||
|
|
path === '/v1/chat/completions'
|
|
) {
|
|
return (
|
|
name === 'authorization' &&
|
|
value === `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
|
|
)
|
|
}
|
|
return false
|
|
}
|
|
|
|
function parseContentLength(
|
|
value: string | undefined
|
|
): number | undefined {
|
|
if (value === undefined) {
|
|
return undefined
|
|
}
|
|
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) {
|
|
throw new HttpRequestError(400, 'request-invalid')
|
|
}
|
|
const length = Number(value)
|
|
if (!Number.isSafeInteger(length)) {
|
|
throw new HttpRequestError(413, 'request-too-large')
|
|
}
|
|
return length
|
|
}
|
|
|
|
async function readBoundedBody(
|
|
incoming: IncomingMessage
|
|
): Promise<Buffer> {
|
|
const chunks: Buffer[] = []
|
|
let total = 0
|
|
for await (const untrustedChunk of incoming) {
|
|
const chunk = Buffer.isBuffer(untrustedChunk)
|
|
? untrustedChunk
|
|
: Buffer.from(untrustedChunk as Uint8Array)
|
|
total += chunk.byteLength
|
|
if (
|
|
total >
|
|
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
|
|
) {
|
|
throw new HttpRequestError(413, 'request-too-large')
|
|
}
|
|
chunks.push(chunk)
|
|
}
|
|
return Buffer.concat(chunks, total)
|
|
}
|
|
|
|
function sendError(
|
|
response: ServerResponse,
|
|
status: number,
|
|
code: string
|
|
): void {
|
|
const body = Buffer.from(JSON.stringify({ error: { code } }), 'utf8')
|
|
response.statusCode = status
|
|
response.setHeader('content-type', 'application/json')
|
|
response.setHeader('content-length', body.byteLength)
|
|
response.end(body)
|
|
}
|
|
|
|
function httpFailure(
|
|
error: unknown,
|
|
signal: AbortSignal
|
|
): { status: number; code: string } {
|
|
if (error instanceof HttpRequestError) {
|
|
return { status: error.status, code: error.code }
|
|
}
|
|
const reason = signal.reason
|
|
if (
|
|
(signal.aborted &&
|
|
reason instanceof ModelBridgeBrokerError &&
|
|
reason.code === 'request-timeout') ||
|
|
(error instanceof ModelBridgeBrokerError &&
|
|
error.code === 'request-timeout')
|
|
) {
|
|
return { status: 504, code: 'bridge-timeout' }
|
|
}
|
|
if (
|
|
signal.aborted ||
|
|
(error instanceof ModelBridgeBrokerError &&
|
|
error.code === 'request-cancelled')
|
|
) {
|
|
return { status: 499, code: 'request-cancelled' }
|
|
}
|
|
return { status: 502, code: 'bridge-failed' }
|
|
}
|
|
|
|
function providerDescriptor(protocol: ModelBridgeProtocol): {
|
|
id: string
|
|
npm:
|
|
| '@ai-sdk/anthropic'
|
|
| '@ai-sdk/openai-compatible'
|
|
| '@ai-sdk/openai'
|
|
requiresSdkAuthSentinel: boolean
|
|
} {
|
|
switch (protocol) {
|
|
case 'anthropic-messages':
|
|
return {
|
|
id: 'goodbuddy-anthropic',
|
|
npm: '@ai-sdk/anthropic',
|
|
requiresSdkAuthSentinel: true
|
|
}
|
|
case 'openai-chat-completions':
|
|
return {
|
|
id: 'goodbuddy-openai-chat',
|
|
npm: '@ai-sdk/openai-compatible',
|
|
requiresSdkAuthSentinel: true
|
|
}
|
|
case 'openai-responses':
|
|
return {
|
|
id: 'goodbuddy-openai-responses',
|
|
npm: '@ai-sdk/openai',
|
|
requiresSdkAuthSentinel: true
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeExchangeResult(
|
|
value: Awaited<ReturnType<ModelBridgeExchange>>
|
|
): ModelBridgeExchangeResult {
|
|
if (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
'response' in value &&
|
|
typeof value.acknowledgeDelivery === 'function' &&
|
|
typeof value.failDelivery === 'function'
|
|
) {
|
|
return value
|
|
}
|
|
return {
|
|
response: remoteModelGatewayResponseSchema.parse(value),
|
|
acknowledgeDelivery: async () => undefined,
|
|
failDelivery: () => undefined
|
|
}
|
|
}
|
|
|
|
function defaultHelperSpawn(
|
|
executable: string,
|
|
args: readonly string[],
|
|
options: {
|
|
shell: false
|
|
stdio: 'inherit'
|
|
env: Readonly<NodeJS.ProcessEnv>
|
|
}
|
|
): ModelBridgeHelperChild {
|
|
return nodeSpawn(executable, [...args], {
|
|
shell: options.shell,
|
|
stdio: options.stdio,
|
|
env: options.env
|
|
})
|
|
}
|
|
|
|
function credentialFreeHelperEnvironment(
|
|
source: Readonly<NodeJS.ProcessEnv>,
|
|
config: string
|
|
): Readonly<NodeJS.ProcessEnv> {
|
|
const allowed = [
|
|
'HOME',
|
|
'LANG',
|
|
'LC_ALL',
|
|
'PATH',
|
|
'TMPDIR',
|
|
'XDG_CACHE_HOME',
|
|
'XDG_CONFIG_HOME',
|
|
'XDG_DATA_HOME',
|
|
'XDG_STATE_HOME'
|
|
] as const
|
|
const environment: NodeJS.ProcessEnv = {
|
|
...runtimePrivacyEnvironment,
|
|
OPENCODE_CONFIG_CONTENT: config,
|
|
|
|
OPENCODE_ENABLE_QUESTION_TOOL: 'true'
|
|
}
|
|
for (const name of allowed) {
|
|
const value = source[name]
|
|
if (
|
|
value === undefined ||
|
|
value.includes('\0') ||
|
|
/(?:key|secret|token|authorization)/iu.test(name)
|
|
) {
|
|
throw new Error(`Fixed helper environment is missing ${name}`)
|
|
}
|
|
environment[name] = value
|
|
}
|
|
for (const name of [
|
|
'OPENCODE_CONFIG_DIR',
|
|
'OPENCODE_DISABLE_AUTOUPDATE',
|
|
'OPENCODE_DISABLE_CLAUDE_CODE_SKILLS',
|
|
'OPENCODE_DISABLE_EMBEDDED_WEB_UI',
|
|
'OPENCODE_DISABLE_EXTERNAL_SKILLS',
|
|
'OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER',
|
|
'OPENCODE_DISABLE_LSP_DOWNLOAD',
|
|
'OPENCODE_DISABLE_MODELS_FETCH',
|
|
'OPENCODE_DISABLE_PROJECT_CONFIG',
|
|
'OPENCODE_DISABLE_SHARE'
|
|
]) {
|
|
const value = source[name]
|
|
if (value !== undefined) {
|
|
if (value.includes('\0')) {
|
|
throw new Error(`Invalid helper environment value for ${name}`)
|
|
}
|
|
environment[name] = value
|
|
}
|
|
}
|
|
return environment
|
|
}
|
|
|
|
function normalizedAbsolutePath(value: string, label: string): string {
|
|
if (
|
|
value.includes('\0') ||
|
|
!isAbsolute(value) ||
|
|
resolve(value) !== value
|
|
) {
|
|
throw new Error(`${label} must be a normalized absolute path`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function parseLoopbackOrigin(input: string): string {
|
|
let url: URL
|
|
try {
|
|
url = new URL(input)
|
|
} catch {
|
|
throw new Error('Model bridge origin is invalid')
|
|
}
|
|
if (
|
|
url.protocol !== 'http:' ||
|
|
url.hostname !== LOOPBACK_HOST ||
|
|
url.username ||
|
|
url.password ||
|
|
!MODEL_BRIDGE_ROUTE_TOKEN_PATTERN.test(
|
|
url.pathname.slice(1)
|
|
) ||
|
|
url.search ||
|
|
url.hash ||
|
|
url.port === ''
|
|
) {
|
|
throw new Error(
|
|
'Model bridge origin must be authenticated loopback HTTP'
|
|
)
|
|
}
|
|
return `${url.origin}${url.pathname}`
|
|
}
|
|
|
|
function parseModelBridgeRouteToken(value: string): string {
|
|
if (!MODEL_BRIDGE_ROUTE_TOKEN_PATTERN.test(value)) {
|
|
throw new Error('Model bridge route capability is invalid')
|
|
}
|
|
return value
|
|
}
|
|
|
|
function authenticatedApiPath(
|
|
input: string | undefined,
|
|
routeToken: string
|
|
): string | undefined {
|
|
const prefix = `/${routeToken}`
|
|
return input?.startsWith(`${prefix}/`)
|
|
? input.slice(prefix.length)
|
|
: undefined
|
|
}
|
|
|
|
function boundedMetadataText(
|
|
value: string,
|
|
label: string
|
|
): string {
|
|
if (
|
|
value.length < 1 ||
|
|
Buffer.byteLength(value, 'utf8') > 512 ||
|
|
/[\0\r\n]/u.test(value)
|
|
) {
|
|
throw new Error(`${label} is invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function listenOnLoopback(server: Server): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const onError = (error: Error): void => {
|
|
cleanup()
|
|
reject(error)
|
|
}
|
|
const onListening = (): void => {
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
const cleanup = (): void => {
|
|
server.off('error', onError)
|
|
server.off('listening', onListening)
|
|
}
|
|
server.once('error', onError)
|
|
server.once('listening', onListening)
|
|
server.listen({
|
|
host: LOOPBACK_HOST,
|
|
port: 0,
|
|
exclusive: true
|
|
})
|
|
})
|
|
}
|
|
|
|
|
|
class HttpRequestError extends Error {
|
|
readonly status: number
|
|
readonly code: string
|
|
|
|
constructor(status: number, code: string) {
|
|
super('Model bridge HTTP request was rejected')
|
|
this.name = 'HttpRequestError'
|
|
this.status = status
|
|
this.code = code
|
|
}
|
|
}
|