Multiple projects could spawn duplicate heavyweight runtimes, while settings changes and remote reconnection could cancel or strand native conversations. Share compatible local OpenCode and DeepSeek Harness process owners and Agent-owned ACP connections, retaining workspace routing, per-session modes, model attribution, question ownership, targeted cancellation and idle cleanup. Restore remote pending questions and terminal task projections across reconnect and desktop restart, and preserve accepted ordinary requests during settings replacement while cancelling expert-team parents with their replaced scheduler. Extend regression coverage and Agent type checks. Record measured resource and real-model validation results, including failures and limitations. Also include the conversation media-generation design drafts (not an implemented feature), documentation entry links and dedicated Linux test-directory guidance. Desktop and Agent changes must be delivered together for shared process results. Validation: typecheck and lint passed; staged content and documentation links checked. Prior full regression and live-model evidence are recorded in progress notes; no new full test suite or model calls were run for this commit. Release note: 多项目会话现在复用兼容的 OpenCode 和 DeepSeek Harness 进程,减少重复启动并保持工作区、工具和取消隔离。修复远端问答在断线、重启及配置更新后的恢复与状态同步问题,Desktop 与 Agent 需配套更新。
948 lines
29 KiB
TypeScript
948 lines
29 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
|
|
|
|
runOnUnix('routes shared Sessions concurrently and rejects queued work after its operation ends', async () => {
|
|
let unblock!: () => void
|
|
const blocked = new Promise<void>(resolve => { unblock = resolve })
|
|
const dispatchOne = vi.fn(async () => { await blocked; return validResponse })
|
|
const dispatchTwo = vi.fn(async () => validResponse)
|
|
const brokers = [dispatchOne, dispatchTwo].map(dispatch => new ModelBridgeBrokerServer({
|
|
scratchDirectory: privateTemporaryDirectory(), dispatch
|
|
}))
|
|
const fallback = vi.fn(async () => validResponse)
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange: fallback, sharedSessions: true })
|
|
await Promise.all(brokers.map(broker => broker.listen()))
|
|
const origin = await proxy.listen()
|
|
const route = (sessionId: string, operationId: string, index: number) => fetch(`${origin}/session`, {
|
|
method: 'POST', body: JSON.stringify({ sessionId, operationId, socketPath: brokers[index]!.socketPath, workMode: 'execute' })
|
|
})
|
|
const prompt = (sessionId: string, operationId: string) => fetch(`${origin}/v1/messages`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json', 'x-goodbuddy-session': sessionId, 'x-goodbuddy-operation': operationId },
|
|
body: '{"model":"fixture"}'
|
|
})
|
|
try {
|
|
expect((await route('one', 'operation-1', 0)).ok).toBe(true)
|
|
expect((await route('two', 'operation-2', 1)).ok).toBe(true)
|
|
const first = prompt('one', 'operation-1')
|
|
await vi.waitFor(() => expect(dispatchOne).toHaveBeenCalledOnce())
|
|
const queued = prompt('one', 'operation-1')
|
|
expect((await prompt('two', 'operation-2')).status).toBe(201)
|
|
await route('one', 'next-operation', 0)
|
|
unblock()
|
|
expect((await first).status).toBe(201)
|
|
expect((await queued).status).toBe(409)
|
|
expect((await prompt('one', 'operation-1')).status).toBe(409)
|
|
expect((await prompt('one', 'next-operation')).status).toBe(201)
|
|
expect(dispatchOne).toHaveBeenCalledTimes(2)
|
|
expect(fallback).not.toHaveBeenCalled()
|
|
for (const dispatch of [dispatchOne, dispatchTwo]) {
|
|
expect(dispatch.mock.calls[0]?.[0]).not.toHaveProperty('headers.x-goodbuddy-session')
|
|
}
|
|
} finally {
|
|
unblock()
|
|
await proxy.close()
|
|
await Promise.all(brokers.map(broker => broker.close()))
|
|
}
|
|
})
|
|
|
|
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
|
|
)
|
|
}
|