Connect Dify, FastGPT and RAGFlow through read-only bindings, encrypted local credentials, shared retrieval and persistent citations. Configuration and removal affect GoodBuddy only. Show active conversations across projects, retain older live work, and improve project dialog scrolling. Queue concurrent questions and route managed SSH OpenCode questions through the Agent. Preserve recoverable read/search errors and partial search results. Document the Electron UI automation standard, real model and external knowledge validation, and remaining acceptance limits. Validation: earlier full regression passed 4081 tests with 66 skipped; subsequent focused remote-question checks and real Linux evidence are recorded in feature progress. Precommit typecheck and lint passed; latest project dialog and shared UI suites passed 58 tests. The full suite was not rerun for the final dialog changes. Release note: Use external knowledge in conversations, find active work across projects, and answer concurrent local or managed SSH OpenCode questions. Managed SSH questions require the updated Agent adapter.
904 lines
27 KiB
TypeScript
904 lines
27 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdtempSync,
|
|
rmSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import {
|
|
request as httpRequest,
|
|
type RequestOptions
|
|
} from 'node:http'
|
|
import { tmpdir } from 'node:os'
|
|
import net from 'node:net'
|
|
import { Duplex } from 'node:stream'
|
|
import { join, resolve } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
REMOTE_MODEL_GATEWAY_LIMITS,
|
|
type RemoteModelGatewayRequest,
|
|
type RemoteModelGatewayResponse
|
|
} from '../shared/remote-model-gateway-contracts'
|
|
import {
|
|
createOpenCodeModelBridgeProviderConfig,
|
|
MODEL_BRIDGE_SDK_AUTH_SENTINEL,
|
|
ModelBridgeLoopbackProxy,
|
|
runOpenCodeModelBridgeHelper,
|
|
type ModelBridgeHelperSpawn
|
|
} from './model-bridge-helper'
|
|
import { createOpenCodeLaunchProfile } from './opencode-runtime-profile'
|
|
import { createRuntimeBundleTestFixture } from './runtime-bundle-test-fixture'
|
|
import {
|
|
createUnixModelBridgeExchange,
|
|
MODEL_BRIDGE_BROKER_SOCKET_NAME,
|
|
ModelBridgeBrokerServer,
|
|
type ModelBridgeExchange
|
|
} from './model-bridge-broker'
|
|
|
|
const temporaryPaths: string[] = []
|
|
const modelBridgeRouteToken = 'A'.repeat(43)
|
|
const validResponse: RemoteModelGatewayResponse = {
|
|
status: 201,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-request-id': 'provider-request'
|
|
},
|
|
bodyBase64: Buffer.from('{"ok":true}', 'utf8').toString(
|
|
'base64'
|
|
)
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
for (const path of temporaryPaths.splice(0)) {
|
|
rmSync(path, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
describe('model bridge loopback helper', () => {
|
|
it.each(['ask', 'execute'] as const)(
|
|
'preserves the %s launch profile offline resources in the final OpenCode spawn',
|
|
async (workMode) => {
|
|
const fixture = await createRuntimeBundleTestFixture()
|
|
temporaryPaths.push(fixture.root)
|
|
const socketPath = join(fixture.root, 'bridge', 'model.sock')
|
|
const profile = createOpenCodeLaunchProfile({
|
|
...fixture,
|
|
workspaceDirectory: fixture.root,
|
|
workMode,
|
|
modelBridge: {
|
|
agentExecutablePath: join(fixture.root, 'agent', 'goodbuddy-agent'),
|
|
bridgeDirectory: join(fixture.root, 'bridge'),
|
|
socketPath,
|
|
policy: {
|
|
protocol: 'anthropic-messages',
|
|
model: 'private-model',
|
|
modelProfileDigest: `sha256:${'9'.repeat(64)}`,
|
|
supportsImageInput: false
|
|
}
|
|
}
|
|
})
|
|
const spawn = vi.fn<ModelBridgeHelperSpawn>(() => {
|
|
const child = new EventEmitter()
|
|
queueMicrotask(() => child.emit('close', 0, null))
|
|
return child
|
|
})
|
|
const resources = {
|
|
HOME: fixture.root,
|
|
PATH: join(fixture.root, 'tools'),
|
|
TMPDIR: join(fixture.root, 'tmp'),
|
|
XDG_CACHE_HOME: join(fixture.root, 'cache'),
|
|
XDG_CONFIG_HOME: join(fixture.root, 'config'),
|
|
XDG_DATA_HOME: join(fixture.root, 'data'),
|
|
XDG_STATE_HOME: join(fixture.root, 'state')
|
|
}
|
|
await expect(runOpenCodeModelBridgeHelper({
|
|
socketPath,
|
|
protocol: 'anthropic-messages',
|
|
model: 'private-model',
|
|
supportsImageInput: false,
|
|
workMode,
|
|
opencodeEntrypoint: join(fixture.bundleDirectory, 'bin', 'opencode'),
|
|
environment: {
|
|
...profile.env,
|
|
...resources,
|
|
OPENCODE_CONFIG_CONTENT: '{"model":"must-not-be-used"}',
|
|
OPENCODE_CONFIG: '/unrelated/config.json',
|
|
ANTHROPIC_API_KEY: 'must-not-be-forwarded',
|
|
NODE_OPTIONS: '--inspect'
|
|
},
|
|
spawn
|
|
})).resolves.toBe(0)
|
|
expect(spawn).toHaveBeenCalledOnce()
|
|
const [executable, args, options] = spawn.mock.calls[0]!
|
|
expect(executable).toBe(join(fixture.bundleDirectory, 'bin', 'opencode'))
|
|
expect(args).toEqual(['acp'])
|
|
expect(options).toMatchObject({ shell: false, stdio: 'inherit' })
|
|
expect(options.env).toMatchObject({
|
|
OPENCODE_ENABLE_QUESTION_TOOL: 'true',
|
|
...resources,
|
|
LANG: profile.env.LANG,
|
|
LC_ALL: profile.env.LC_ALL,
|
|
OPENCODE_CONFIG_DIR: join(fixture.bundleDirectory, 'config', 'opencode'),
|
|
OPENCODE_DISABLE_AUTOUPDATE: '1',
|
|
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: '1',
|
|
OPENCODE_DISABLE_EMBEDDED_WEB_UI: '1',
|
|
OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
|
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: '1',
|
|
OPENCODE_DISABLE_LSP_DOWNLOAD: '1',
|
|
OPENCODE_DISABLE_MODELS_FETCH: '1',
|
|
OPENCODE_DISABLE_PROJECT_CONFIG: '1',
|
|
OPENCODE_DISABLE_SHARE: '1'
|
|
})
|
|
expect(options.env.OPENCODE_CONFIG).toBeUndefined()
|
|
expect(options.env.ANTHROPIC_API_KEY).toBeUndefined()
|
|
expect(options.env.NODE_OPTIONS).toBeUndefined()
|
|
const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!)
|
|
expect(config.model).toBe('goodbuddy-anthropic/private-model')
|
|
expect(config.snapshot).toBe(false)
|
|
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
|
|
expect(config.plugin).toHaveLength(1)
|
|
}
|
|
)
|
|
|
|
it('forwards only the bounded request contract and returns the response', async () => {
|
|
const exchange = vi.fn<ModelBridgeExchange>(
|
|
async () => validResponse
|
|
)
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const response = await sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
headers: {
|
|
accept: 'application/json',
|
|
'content-type': 'application/json',
|
|
'x-api-key': MODEL_BRIDGE_SDK_AUTH_SENTINEL
|
|
},
|
|
body: '{"model":"private-model","messages":[]}'
|
|
})
|
|
|
|
expect(response).toEqual({
|
|
status: 201,
|
|
headers: expect.objectContaining({
|
|
'content-type': 'application/json',
|
|
'x-request-id': 'provider-request'
|
|
}),
|
|
body: '{"ok":true}'
|
|
})
|
|
expect(exchange).toHaveBeenCalledOnce()
|
|
const [request, context] = exchange.mock.calls[0]!
|
|
expect(request).toEqual({
|
|
method: 'POST',
|
|
path: '/v1/messages',
|
|
headers: {
|
|
accept: 'application/json',
|
|
'content-type': 'application/json'
|
|
},
|
|
bodyBase64: Buffer.from(
|
|
'{"model":"private-model","messages":[]}',
|
|
'utf8'
|
|
).toString('base64')
|
|
})
|
|
expect(context.requestId).toMatch(/^model-/u)
|
|
expect(context.signal.aborted).toBe(false)
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('acknowledges delivery only after the HTTP response flushes', async () => {
|
|
const acknowledgeDelivery = vi.fn(async () => undefined)
|
|
const failDelivery = vi.fn(async () => undefined)
|
|
const proxy = new ModelBridgeLoopbackProxy({
|
|
exchange: async () => ({
|
|
response: validResponse,
|
|
acknowledgeDelivery,
|
|
failDelivery
|
|
})
|
|
})
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const response = await sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
body: '{}'
|
|
})
|
|
expect(response.body).toBe('{"ok":true}')
|
|
expect(acknowledgeDelivery).toHaveBeenCalledOnce()
|
|
expect(failDelivery).not.toHaveBeenCalled()
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('flushes and acknowledges a multi-frame-sized HTTP response', async () => {
|
|
const body = Buffer.alloc(600 * 1024, 0x61)
|
|
const acknowledgeDelivery = vi.fn(async () => undefined)
|
|
const failDelivery = vi.fn(async () => undefined)
|
|
const proxy = new ModelBridgeLoopbackProxy({
|
|
exchange: async () => ({
|
|
response: {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/octet-stream' },
|
|
bodyBase64: body.toString('base64')
|
|
},
|
|
acknowledgeDelivery,
|
|
failDelivery
|
|
})
|
|
})
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const response = await sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
body: '{}'
|
|
})
|
|
expect(Buffer.byteLength(response.body)).toBe(body.byteLength)
|
|
expect(acknowledgeDelivery).toHaveBeenCalledOnce()
|
|
expect(failDelivery).not.toHaveBeenCalled()
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
name: 'non-POST method',
|
|
request: { method: 'GET', path: '/v1/messages' },
|
|
status: 405
|
|
},
|
|
{
|
|
name: 'unknown path',
|
|
request: { path: '/images/generations' },
|
|
status: 404
|
|
},
|
|
{
|
|
name: 'path query',
|
|
request: { path: '/v1/messages?token=secret' },
|
|
status: 404
|
|
},
|
|
{
|
|
name: 'credential header',
|
|
request: {
|
|
path: '/v1/messages',
|
|
headers: { authorization: 'Bearer remote-secret' }
|
|
},
|
|
status: 400
|
|
}
|
|
])('rejects $name without dispatch', async ({ request, status }) => {
|
|
const exchange = vi.fn(async () => validResponse)
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const response = await sendHttp(origin, request)
|
|
expect(response.status).toBe(status)
|
|
expect(response.body).not.toContain('remote-secret')
|
|
expect(exchange).not.toHaveBeenCalled()
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('rejects a declared oversized body before dispatch', async () => {
|
|
const exchange = vi.fn(async () => validResponse)
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const response = await sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
headers: {
|
|
'content-length': String(
|
|
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes + 1
|
|
)
|
|
},
|
|
body: ''
|
|
})
|
|
expect(response.status).toBe(413)
|
|
expect(exchange).not.toHaveBeenCalled()
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('rejects loopback requests without the per-helper route capability', async () => {
|
|
const exchange = vi.fn(async () => validResponse)
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const response = await sendHttp(origin, {
|
|
authenticated: false,
|
|
path: '/v1/messages',
|
|
headers: {
|
|
'x-api-key': MODEL_BRIDGE_SDK_AUTH_SENTINEL
|
|
},
|
|
body: '{}'
|
|
})
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(exchange).not.toHaveBeenCalled()
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('drops harmless SDK metadata headers but rejects chunked framing', async () => {
|
|
const exchange = vi.fn<ModelBridgeExchange>(
|
|
async () => validResponse
|
|
)
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const accepted = await sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-stainless-runtime': 'node'
|
|
},
|
|
body: '{}'
|
|
})
|
|
expect(accepted.status).toBe(201)
|
|
expect(exchange).toHaveBeenCalledOnce()
|
|
expect(exchange.mock.calls[0]![0].headers).toEqual({
|
|
'content-type': 'application/json'
|
|
})
|
|
|
|
const rejected = await sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'transfer-encoding': 'chunked'
|
|
},
|
|
body: '{}'
|
|
})
|
|
expect(rejected.status).toBe(400)
|
|
expect(exchange).toHaveBeenCalledOnce()
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('accepts only the fixed SDK compatibility marker and strips it', async () => {
|
|
const exchange = vi.fn<ModelBridgeExchange>(
|
|
async () => validResponse
|
|
)
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange })
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const accepted = await sendHttp(origin, {
|
|
path: '/v1/responses',
|
|
headers: {
|
|
authorization: `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
|
|
},
|
|
body: '{}'
|
|
})
|
|
expect(accepted.status).toBe(201)
|
|
expect(exchange).toHaveBeenCalledOnce()
|
|
expect(exchange.mock.calls[0]![0].headers).toEqual({})
|
|
|
|
const acceptedChat = await sendHttp(origin, {
|
|
path: '/v1/chat/completions',
|
|
headers: {
|
|
authorization: `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
|
|
},
|
|
body: '{}'
|
|
})
|
|
expect(acceptedChat.status).toBe(201)
|
|
expect(exchange).toHaveBeenCalledTimes(2)
|
|
expect(exchange.mock.calls[1]![0].headers).toEqual({})
|
|
|
|
for (const authorization of [
|
|
'Bearer remote-secret',
|
|
`Basic ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
|
|
]) {
|
|
const rejected = await sendHttp(origin, {
|
|
path: '/v1/responses',
|
|
headers: { authorization },
|
|
body: '{}'
|
|
})
|
|
expect(rejected.status).toBe(400)
|
|
}
|
|
expect(exchange).toHaveBeenCalledTimes(2)
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('times out once, aborts dispatch, and never returns raw errors', async () => {
|
|
let dispatchSignal: AbortSignal | undefined
|
|
const exchange = vi.fn(
|
|
async (
|
|
_request: RemoteModelGatewayRequest,
|
|
context: { signal: AbortSignal }
|
|
) => {
|
|
dispatchSignal = context.signal
|
|
await new Promise(() => undefined)
|
|
return validResponse
|
|
}
|
|
)
|
|
const proxy = new ModelBridgeLoopbackProxy({
|
|
exchange,
|
|
requestTimeoutMs: 20
|
|
})
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const response = await sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
body: '{}'
|
|
})
|
|
expect(response.status).toBe(504)
|
|
expect(response.body).toBe(
|
|
'{"error":{"code":"bridge-timeout"}}'
|
|
)
|
|
expect(dispatchSignal?.aborted).toBe(true)
|
|
expect(exchange).toHaveBeenCalledOnce()
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('maps dispatch errors without leaking credentials', async () => {
|
|
const privateValue = 'provider-key-do-not-leak'
|
|
const proxy = new ModelBridgeLoopbackProxy({
|
|
exchange: async () => {
|
|
throw new Error(`Upstream used ${privateValue}`)
|
|
}
|
|
})
|
|
const origin = await proxy.listen()
|
|
try {
|
|
const response = await sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
body: '{}'
|
|
})
|
|
expect(response.status).toBe(502)
|
|
expect(response.body).toBe(
|
|
'{"error":{"code":"bridge-failed"}}'
|
|
)
|
|
expect(response.body).not.toContain(privateValue)
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('queues concurrent OpenCode requests instead of returning bridge-busy', async () => {
|
|
let markStarted: (() => void) | undefined
|
|
let releaseFirst: (() => void) | undefined
|
|
const started = new Promise<void>((resolveStarted) => {
|
|
markStarted = resolveStarted
|
|
})
|
|
const firstGate = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve
|
|
})
|
|
let callIndex = 0
|
|
const exchange = vi.fn(async () => {
|
|
const index = callIndex++
|
|
if (index === 0) {
|
|
markStarted?.()
|
|
await firstGate
|
|
}
|
|
return validResponse
|
|
})
|
|
const proxy = new ModelBridgeLoopbackProxy({
|
|
exchange
|
|
})
|
|
const origin = await proxy.listen()
|
|
const first = sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
body: '{}'
|
|
})
|
|
await started
|
|
const second = sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
body: '{}'
|
|
})
|
|
try {
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 20))
|
|
expect(exchange).toHaveBeenCalledOnce()
|
|
releaseFirst?.()
|
|
await expect(first).resolves.toMatchObject({ status: 201 })
|
|
await expect(second).resolves.toMatchObject({ status: 201 })
|
|
expect(exchange).toHaveBeenCalledTimes(2)
|
|
} finally {
|
|
await proxy.close()
|
|
}
|
|
})
|
|
|
|
it('aborts an in-flight dispatch when the proxy closes', async () => {
|
|
let dispatchSignal: AbortSignal | undefined
|
|
let markStarted: (() => void) | undefined
|
|
const started = new Promise<void>((resolveStarted) => {
|
|
markStarted = resolveStarted
|
|
})
|
|
const proxy = new ModelBridgeLoopbackProxy({
|
|
exchange: async (_request, context) => {
|
|
dispatchSignal = context.signal
|
|
markStarted?.()
|
|
await new Promise(() => undefined)
|
|
return validResponse
|
|
}
|
|
})
|
|
const origin = await proxy.listen()
|
|
const pending = sendHttp(origin, {
|
|
path: '/v1/messages',
|
|
body: '{}'
|
|
}).catch(() => undefined)
|
|
await started
|
|
|
|
await proxy.close()
|
|
await pending
|
|
expect(dispatchSignal?.aborted).toBe(true)
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
protocol: 'anthropic-messages' as const,
|
|
providerId: 'goodbuddy-anthropic',
|
|
npm: '@ai-sdk/anthropic',
|
|
requiresSdkAuthSentinel: true
|
|
},
|
|
{
|
|
protocol: 'openai-chat-completions' as const,
|
|
providerId: 'goodbuddy-openai-chat',
|
|
npm: '@ai-sdk/openai-compatible',
|
|
requiresSdkAuthSentinel: true
|
|
},
|
|
{
|
|
protocol: 'openai-responses' as const,
|
|
providerId: 'goodbuddy-openai-responses',
|
|
npm: '@ai-sdk/openai',
|
|
requiresSdkAuthSentinel: true
|
|
}
|
|
])(
|
|
'creates secret-free $protocol OpenCode metadata',
|
|
({ protocol, providerId, npm, requiresSdkAuthSentinel }) => {
|
|
const config = createOpenCodeModelBridgeProviderConfig({
|
|
protocol,
|
|
model: 'private-model',
|
|
name: 'Private model',
|
|
loopbackOrigin:
|
|
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
|
supportsImageInput: true,
|
|
workMode: 'ask'
|
|
})
|
|
|
|
expect(config).toMatchObject({
|
|
model: `${providerId}/private-model`,
|
|
permission: 'ask',
|
|
agent: {
|
|
title: {
|
|
disable: true
|
|
},
|
|
build: {
|
|
permission: 'ask'
|
|
}
|
|
},
|
|
provider: {
|
|
[providerId]: {
|
|
npm,
|
|
options: {
|
|
baseURL:
|
|
`http://127.0.0.1:12345/${modelBridgeRouteToken}/v1`
|
|
},
|
|
models: {
|
|
'private-model': {
|
|
attachment: true,
|
|
provider: { npm }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
const options = config.provider[providerId]!.options
|
|
expect(options.apiKey).toBe(
|
|
requiresSdkAuthSentinel
|
|
? MODEL_BRIDGE_SDK_AUTH_SENTINEL
|
|
: undefined
|
|
)
|
|
expect(JSON.stringify(config)).not.toContain('provider.example')
|
|
}
|
|
)
|
|
|
|
it('explicitly allows Execute permissions instead of inheriting directory prompts', () => {
|
|
const config = createOpenCodeModelBridgeProviderConfig({
|
|
protocol: 'openai-responses',
|
|
model: 'private-model',
|
|
loopbackOrigin:
|
|
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
|
|
workMode: 'execute'
|
|
})
|
|
|
|
expect(config.permission).toBe('allow')
|
|
expect(config.agent.build.permission).toBe('allow')
|
|
})
|
|
})
|
|
|
|
describe('model bridge Unix broker', () => {
|
|
const runOnUnix = process.platform === 'win32' ? it.skip : it
|
|
|
|
it('allows a delayed connection with the default unlimited request timeout', async () => {
|
|
const socket = new Duplex({
|
|
read() {},
|
|
write(chunk: Buffer, _encoding, callback) {
|
|
const packet = JSON.parse(chunk.subarray(4).toString()) as {
|
|
type: string
|
|
requestId: string
|
|
}
|
|
const response = Buffer.from(JSON.stringify(
|
|
packet.type === 'model-request'
|
|
? { version: 1, type: 'model-response', requestId: packet.requestId, ok: true, response: validResponse }
|
|
: { version: 1, type: 'delivery-accepted', requestId: packet.requestId }
|
|
))
|
|
const frame = Buffer.alloc(4 + response.length)
|
|
frame.writeUInt32BE(response.length)
|
|
response.copy(frame, 4)
|
|
this.push(frame)
|
|
callback()
|
|
}
|
|
})
|
|
vi.spyOn(net.Socket.prototype, 'connect').mockReturnValueOnce(socket as net.Socket)
|
|
const connect = setTimeout(() => socket.emit('connect'), 30)
|
|
try {
|
|
const exchange = createUnixModelBridgeExchange({
|
|
socketPath: resolve(tmpdir(), 'delayed-model-bridge.sock'),
|
|
connectTimeoutMs: 500
|
|
})
|
|
const result = await exchange({
|
|
method: 'POST', path: '/v1/messages', headers: {}, bodyBase64: ''
|
|
}, { requestId: 'delayed-connect', signal: new AbortController().signal })
|
|
expect('response' in result).toBe(true)
|
|
if ('response' in result) {
|
|
expect(result.response).toEqual(validResponse)
|
|
await result.acknowledgeDelivery()
|
|
}
|
|
} finally {
|
|
clearTimeout(connect)
|
|
socket.destroy()
|
|
}
|
|
})
|
|
|
|
runOnUnix('rejects a socket path beyond the Unix kernel limit', () => {
|
|
const scratch = resolve(
|
|
privateTemporaryDirectory(),
|
|
'a'.repeat(150)
|
|
)
|
|
expect(
|
|
() =>
|
|
new ModelBridgeBrokerServer({
|
|
scratchDirectory: scratch,
|
|
dispatch: async () => validResponse
|
|
})
|
|
).toThrow('endpoint path is too long')
|
|
})
|
|
|
|
runOnUnix(
|
|
'uses a private owned socket, exchanges one request, and cleans up',
|
|
async () => {
|
|
const scratch = privateTemporaryDirectory()
|
|
const dispatch = vi.fn(async () => validResponse)
|
|
const broker = new ModelBridgeBrokerServer({
|
|
scratchDirectory: scratch,
|
|
dispatch
|
|
})
|
|
await broker.listen()
|
|
const socketPath = resolve(
|
|
scratch,
|
|
MODEL_BRIDGE_BROKER_SOCKET_NAME
|
|
)
|
|
expect(lstatSync(socketPath).mode & 0o777).toBe(0o600)
|
|
|
|
const exchange = createUnixModelBridgeExchange({
|
|
socketPath
|
|
})
|
|
const request: RemoteModelGatewayRequest = {
|
|
method: 'POST',
|
|
path: '/v1/responses',
|
|
headers: { 'content-type': 'application/json' },
|
|
bodyBase64: Buffer.from('{}').toString('base64')
|
|
}
|
|
const exchanged = await exchange(request, {
|
|
requestId: 'request-a',
|
|
signal: new AbortController().signal
|
|
})
|
|
expect(
|
|
'response' in exchanged ? exchanged.response : exchanged
|
|
).toEqual(validResponse)
|
|
if ('response' in exchanged) {
|
|
await exchanged.acknowledgeDelivery()
|
|
}
|
|
expect(dispatch).toHaveBeenCalledWith(request, {
|
|
requestId: 'request-a',
|
|
signal: expect.any(AbortSignal)
|
|
})
|
|
|
|
await broker.close()
|
|
expect(existsSync(socketPath)).toBe(false)
|
|
}
|
|
)
|
|
|
|
runOnUnix(
|
|
'drains a response larger than the Unix socket receive buffer',
|
|
async () => {
|
|
const scratch = privateTemporaryDirectory()
|
|
const responseBody = Buffer.alloc(256 * 1024, 0x61)
|
|
const broker = new ModelBridgeBrokerServer({
|
|
scratchDirectory: scratch,
|
|
dispatch: async () => ({
|
|
...validResponse,
|
|
bodyBase64: responseBody.toString('base64')
|
|
})
|
|
})
|
|
await broker.listen()
|
|
try {
|
|
const exchange = createUnixModelBridgeExchange({
|
|
socketPath: broker.socketPath,
|
|
requestTimeoutMs: 2_000
|
|
})
|
|
const exchanged = await exchange(
|
|
{
|
|
method: 'POST',
|
|
path: '/v1/responses',
|
|
headers: { 'content-type': 'application/json' },
|
|
bodyBase64: Buffer.from('{}').toString('base64')
|
|
},
|
|
{
|
|
requestId: 'request-large-response',
|
|
signal: new AbortController().signal
|
|
}
|
|
)
|
|
expect('response' in exchanged).toBe(true)
|
|
if ('response' in exchanged) {
|
|
expect(
|
|
Buffer.from(
|
|
exchanged.response.bodyBase64,
|
|
'base64'
|
|
).byteLength
|
|
).toBe(responseBody.byteLength)
|
|
await exchanged.acknowledgeDelivery()
|
|
}
|
|
} finally {
|
|
await broker.close()
|
|
}
|
|
}
|
|
)
|
|
|
|
runOnUnix(
|
|
'refuses to replace or remove a non-socket endpoint path',
|
|
async () => {
|
|
const scratch = privateTemporaryDirectory()
|
|
const socketPath = join(
|
|
scratch,
|
|
MODEL_BRIDGE_BROKER_SOCKET_NAME
|
|
)
|
|
writeFileSync(socketPath, 'user-owned')
|
|
chmodSync(socketPath, 0o600)
|
|
const broker = new ModelBridgeBrokerServer({
|
|
scratchDirectory: scratch,
|
|
dispatch: async () => validResponse
|
|
})
|
|
|
|
await expect(broker.listen()).rejects.toThrow(
|
|
'not an owned Unix socket'
|
|
)
|
|
await broker.close()
|
|
expect(existsSync(socketPath)).toBe(true)
|
|
}
|
|
)
|
|
|
|
runOnUnix(
|
|
'bounds dispatch time and forwards only a generic failure code',
|
|
async () => {
|
|
const scratch = privateTemporaryDirectory()
|
|
const privateValue = 'provider-secret-in-error'
|
|
let dispatchSignal: AbortSignal | undefined
|
|
const broker = new ModelBridgeBrokerServer({
|
|
scratchDirectory: scratch,
|
|
requestTimeoutMs: 20,
|
|
dispatch: async (_request, context) => {
|
|
dispatchSignal = context.signal
|
|
await new Promise(() => undefined)
|
|
throw new Error(privateValue)
|
|
}
|
|
})
|
|
await broker.listen()
|
|
try {
|
|
const exchange = createUnixModelBridgeExchange({
|
|
socketPath: broker.socketPath,
|
|
requestTimeoutMs: 1_000
|
|
})
|
|
await expect(
|
|
exchange(
|
|
{
|
|
method: 'POST',
|
|
path: '/v1/messages',
|
|
headers: {},
|
|
bodyBase64: ''
|
|
},
|
|
{
|
|
requestId: 'request-timeout',
|
|
signal: new AbortController().signal
|
|
}
|
|
)
|
|
).rejects.toMatchObject({
|
|
code: 'request-timeout',
|
|
message: 'Model bridge request timed out'
|
|
})
|
|
expect(dispatchSignal?.aborted).toBe(true)
|
|
} finally {
|
|
await broker.close()
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
function privateTemporaryDirectory(): string {
|
|
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-model-bridge-'))
|
|
temporaryPaths.push(path)
|
|
if (process.platform !== 'win32') {
|
|
chmodSync(path, 0o700)
|
|
}
|
|
return resolve(path)
|
|
}
|
|
|
|
async function sendHttp(
|
|
origin: string,
|
|
options: {
|
|
method?: string
|
|
path?: string
|
|
authenticated?: boolean
|
|
headers?: Record<string, string>
|
|
body?: string
|
|
}
|
|
): Promise<{
|
|
status: number
|
|
headers: Record<string, string | string[] | undefined>
|
|
body: string
|
|
}> {
|
|
const url = new URL(origin)
|
|
const body = options.body ?? ''
|
|
const requestPath = options.path ?? '/v1/messages'
|
|
const basePath =
|
|
url.pathname === '/'
|
|
? ''
|
|
: url.pathname.replace(/\/$/u, '')
|
|
const requestOptions: RequestOptions = {
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
method: options.method ?? 'POST',
|
|
path:
|
|
options.authenticated === false
|
|
? requestPath
|
|
: `${basePath}${requestPath}`,
|
|
headers: {
|
|
...(options.headers ?? {}),
|
|
...(!hasHeader(options.headers, 'content-length') &&
|
|
!hasHeader(options.headers, 'transfer-encoding')
|
|
? { 'content-length': Buffer.byteLength(body) }
|
|
: {})
|
|
}
|
|
}
|
|
return await new Promise((resolveResponse, reject) => {
|
|
const request = httpRequest(requestOptions, (response) => {
|
|
const chunks: Buffer[] = []
|
|
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
response.once('end', () => {
|
|
resolveResponse({
|
|
status: response.statusCode ?? 0,
|
|
headers: response.headers,
|
|
body: Buffer.concat(chunks).toString('utf8')
|
|
})
|
|
})
|
|
})
|
|
request.once('error', reject)
|
|
request.end(body)
|
|
})
|
|
}
|
|
|
|
function hasHeader(
|
|
headers: Record<string, string> | undefined,
|
|
expected: string
|
|
): boolean {
|
|
return Object.keys(headers ?? {}).some(
|
|
(name) => name.toLowerCase() === expected
|
|
)
|
|
}
|