Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Automatic native Git snapshot diffs could block concurrent OpenCode sessions after broad rewrites or line-ending conversions. Managed desktop and Agent profiles now skip snapshots that GoodBuddy does not use, while preserving file tools, native subagents, workspace Git operations, and Ask/Execute rules. Drain otherwise-unused Continue utility-host stdout to prevent console output from filling the pipe and blocking requests. Correct the lifecycle regression's body observer to watch the underlying reader rather than a buffered wrapper. Prepare Desktop 0.12.10 and Agent 0.11.22 with aligned bilingual notes and feature documentation. Existing runtimes require restart or Agent update. Full desktop UI before/after and Linux Agent production-path checks passed; 3626 tests passed, with 61 existing platform/manual skips. Release note: 修复批量文件改写或换行转换后的托管 OpenCode 并行停顿,以及 Continue 控制台输出阻塞;本机升级重启后生效,远程项目请配套更新 Agent。
861 lines
25 KiB
TypeScript
861 lines
25 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 { 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({
|
|
...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('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,
|
|
requestTimeoutMs: 1_000
|
|
})
|
|
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
|
|
)
|
|
}
|