Files
goodbuddy/src/agent-daemon/model-bridge-broker.ts
T
mesalogo c037d7fb20 fix: complete runtime and workspace workflows
Restore Ask workspace reads and output paging without granting write access,
and let programming Subagents reuse their parent's browser page. Allocate
browser resources only when used, keep Agent pages aligned with workbar tabs,
recover released bindings, and remove unused automatic screenshot work.

Preserve inherited Runtime selections during remote usage persistence and
manual compression. Keep viable channel model fallbacks, display the actual
scheduled-task mode and Harness model source, and avoid rediscovery delays
when calling healthy MCP tools. Fix directory/history refresh, case-only
renames, rename-aware commit diffs, and FastGPT probe success reporting.

Add regression coverage and align feature documentation. The Agent-side
connection timeout fix requires deploying an updated Agent; existing Ask
read-only and Execute authorization boundaries remain unchanged.

Final validation: 3839 tests passed, 66 skipped; typecheck, lint, development
build, and diff checks passed. Isolated Electron UI/MCP workflows passed with
a loopback model fixture. Current-source Linux x64 Host checks passed with
three successful real model requests. Chromium loadURL rejection was not
reproduced in the UI; released-binding recovery has automated coverage.

Release note: 修复 Ask 读取项目文件、浏览器页面显示与恢复、文件管理及模型配置跟随中的不一致,并减少无用截图和 MCP 等待。定时任务显示实际工作模式,远程会话可正常保存继承 Runtime 的用量;远端连接超时修复需更新 Agent 后生效。
2026-09-10 12:48:30 +08:00

829 lines
21 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import {
chmodSync,
lstatSync,
type Stats,
unlinkSync
} from 'node:fs'
import {
createConnection,
createServer,
type Server,
type Socket
} from 'node:net'
import { dirname, join } from 'node:path'
import { TextDecoder } from 'node:util'
import { z } from 'zod'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../shared/remote-model-gateway-contracts'
import {
assertAbsoluteManagedPath,
ensurePrivateDirectory
} from './managed-paths'
import { AgentUnsupportedError } from './errors'
import {
boundedInteger,
closeServer,
isNodeError,
raceWithAbort,
settleWithin
} from './async-utils'
export const MODEL_BRIDGE_BROKER_SOCKET_NAME =
'model-bridge.sock'
const BROKER_PROTOCOL_VERSION = 1
const DEFAULT_MAXIMUM_CONNECTIONS = 8
const DEFAULT_REQUEST_TIMEOUT_MS = 0
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
const CLOSE_TIMEOUT_MS = 2_000
const MAXIMUM_UNIX_SOCKET_PATH_BYTES = 107
const MAXIMUM_FRAME_BYTES =
Math.ceil(
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes / 3
) *
4 +
64 * 1_024
const requestIdSchema = z
.string()
.min(1)
.max(128)
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u)
const brokerRequestPacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('model-request'),
requestId: requestIdSchema,
request: remoteModelGatewayRequestSchema
})
.strict()
const brokerSuccessPacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('model-response'),
requestId: requestIdSchema,
ok: z.literal(true),
response: remoteModelGatewayResponseSchema
})
.strict()
const brokerFailureCodeSchema = z.enum([
'request-invalid',
'request-timeout',
'request-cancelled',
'dispatch-failed'
])
const brokerFailurePacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('model-response'),
requestId: requestIdSchema,
ok: z.literal(false),
error: z
.object({
code: brokerFailureCodeSchema
})
.strict()
})
.strict()
const brokerResponsePacketSchema = z.discriminatedUnion('ok', [
brokerSuccessPacketSchema,
brokerFailurePacketSchema
])
const brokerDeliveryPacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('response-delivered'),
requestId: requestIdSchema
})
.strict()
const brokerDeliveryAcceptedPacketSchema = z
.object({
version: z.literal(BROKER_PROTOCOL_VERSION),
type: z.literal('delivery-accepted'),
requestId: requestIdSchema
})
.strict()
export type ModelBridgeDispatchContext = {
requestId: string
signal: AbortSignal
}
export type ModelBridgeExchangeResult = {
response: RemoteModelGatewayResponse
acknowledgeDelivery(): Promise<void>
failDelivery(reason?: unknown): void | Promise<void>
}
export type ModelBridgeBrokerDispatch = (
request: RemoteModelGatewayRequest,
context: ModelBridgeDispatchContext
) => Promise<ModelBridgeExchangeResult | RemoteModelGatewayResponse>
export type ModelBridgeExchange = (
request: RemoteModelGatewayRequest,
context: ModelBridgeDispatchContext
) => Promise<ModelBridgeExchangeResult | RemoteModelGatewayResponse>
export class ModelBridgeBrokerError extends Error {
readonly code:
| 'request-invalid'
| 'request-timeout'
| 'request-cancelled'
| 'dispatch-failed'
| 'transport-failed'
constructor(code: ModelBridgeBrokerError['code']) {
super(modelBridgeBrokerErrorMessage(code))
this.name = 'ModelBridgeBrokerError'
this.code = code
}
}
export class ModelBridgeBrokerServer {
readonly socketPath: string
readonly #dispatch: ModelBridgeBrokerDispatch
readonly #maximumConnections: number
readonly #requestTimeoutMs: number
readonly #connections = new Set<Socket>()
#server?: Server
#socketIdentity?: FileIdentity
#closing = false
#closePromise?: Promise<void>
constructor(options: {
scratchDirectory: string
dispatch: ModelBridgeBrokerDispatch
maximumConnections?: number
requestTimeoutMs?: number
}) {
const scratchDirectory = assertAbsoluteManagedPath(
options.scratchDirectory
)
this.socketPath = assertAbsoluteManagedPath(
join(scratchDirectory, MODEL_BRIDGE_BROKER_SOCKET_NAME)
)
if (
process.platform !== 'win32' &&
Buffer.byteLength(this.socketPath, 'utf8') >
MAXIMUM_UNIX_SOCKET_PATH_BYTES
) {
throw new Error('Model bridge endpoint path is too long')
}
this.#dispatch = options.dispatch
this.#maximumConnections = boundedInteger(
options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS,
1,
64,
'Model bridge connection limit'
)
this.#requestTimeoutMs = boundedInteger(
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
0,
0x7fff_ffff,
'Model bridge request timeout'
)
}
async listen(): Promise<void> {
if (process.platform === 'win32') {
throw new AgentUnsupportedError(
'Unix model bridge brokers are unavailable on Windows',
'platform-incompatible'
)
}
if (this.#server !== undefined || this.#closing) {
throw new Error('Model bridge broker is already active')
}
ensurePrivateDirectory(dirname(this.socketPath), {
create: false
})
removeOwnedStaleSocket(this.socketPath)
const server = createServer(
{ pauseOnConnect: true },
(socket) => {
if (
this.#closing ||
this.#connections.size >= this.#maximumConnections
) {
socket.destroy()
return
}
this.#connections.add(socket)
socket.once('close', () => {
this.#connections.delete(socket)
})
void this.#serveOne(socket)
}
)
this.#server = server
try {
await listenOnUnixSocket(server, this.socketPath)
const initialStat = lstatSync(this.socketPath)
this.#socketIdentity = fileIdentity(initialStat)
assertOwnedSocket(initialStat, 'Model bridge endpoint')
chmodSync(this.socketPath, 0o600)
const securedStat = lstatSync(this.socketPath)
assertSameFile(securedStat, this.#socketIdentity)
assertOwnedSocket(securedStat, 'Model bridge endpoint')
if ((securedStat.mode & 0o777) !== 0o600) {
throw new Error('Model bridge endpoint mode verification failed')
}
} 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.#closing = true
try {
for (const connection of this.#connections) {
connection.destroy()
}
await settleWithin(
server === undefined
? Promise.resolve()
: closeServer(server),
CLOSE_TIMEOUT_MS
)
} finally {
this.#connections.clear()
removeSocketIfIdentityMatches(
this.socketPath,
this.#socketIdentity
)
this.#socketIdentity = undefined
this.#closing = false
}
}
async #serveOne(socket: Socket): Promise<void> {
const cancellation = new AbortController()
const onClose = (): void => {
cancellation.abort(
new ModelBridgeBrokerError('request-cancelled')
)
}
socket.once('close', onClose)
const timeout = this.#requestTimeoutMs === 0 ? undefined : setTimeout(() => {
cancellation.abort(
new ModelBridgeBrokerError('request-timeout')
)
}, this.#requestTimeoutMs)
timeout?.unref?.()
let requestId = `invalid-${randomUUID()}`
let dispatched: ModelBridgeExchangeResult | undefined
let delivered = false
try {
const packet = brokerRequestPacketSchema.parse(
await readPacket(socket, cancellation.signal)
)
requestId = packet.requestId
dispatched = normalizeExchangeResult(await raceWithAbort(
this.#dispatch(packet.request, {
requestId,
signal: cancellation.signal
}),
cancellation.signal
))
const response = remoteModelGatewayResponseSchema.parse(
dispatched.response
)
cancellation.signal.throwIfAborted()
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'model-response',
requestId,
ok: true,
response
})
const delivery = brokerDeliveryPacketSchema.parse(
await readPacket(socket, cancellation.signal)
)
if (delivery.requestId !== requestId) {
throw new ModelBridgeBrokerError('transport-failed')
}
await raceWithAbort(
Promise.resolve(dispatched.acknowledgeDelivery()),
cancellation.signal
)
delivered = true
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'delivery-accepted',
requestId
})
} catch (error) {
if (dispatched !== undefined && !delivered) {
await Promise.resolve(
dispatched.failDelivery(error)
).catch(() => undefined)
} else if (!socket.destroyed) {
await writeFailure(
socket,
requestId,
failureCode(error, cancellation.signal)
).catch(() => undefined)
}
} finally {
clearTimeout(timeout)
socket.off('close', onClose)
if (!socket.destroyed) {
socket.end()
}
}
}
}
export function createUnixModelBridgeExchange(options: {
socketPath: string
connectTimeoutMs?: number
requestTimeoutMs?: number
}): ModelBridgeExchange {
const socketPath = assertAbsoluteManagedPath(options.socketPath)
const connectTimeoutMs = boundedInteger(
options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
1,
60_000,
'Model bridge connect timeout'
)
const requestTimeoutMs = boundedInteger(
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
0,
0x7fff_ffff,
'Model bridge request timeout'
)
return async (request, context) => {
const parsedRequest = remoteModelGatewayRequestSchema.parse(request)
const requestId = requestIdSchema.parse(context.requestId)
context.signal.throwIfAborted()
const socket = createConnection(socketPath)
const timeoutController = new AbortController()
const timeout = requestTimeoutMs === 0 ? undefined : setTimeout(() => {
timeoutController.abort(
new ModelBridgeBrokerError('request-timeout')
)
socket.destroy()
}, requestTimeoutMs)
timeout?.unref?.()
const onAbort = (): void => {
socket.destroy()
}
context.signal.addEventListener('abort', onAbort, { once: true })
let responseHandedOff = false
const cleanup = (): void => {
clearTimeout(timeout)
context.signal.removeEventListener('abort', onAbort)
socket.destroy()
}
try {
await waitForConnection(
socket,
requestTimeoutMs === 0
? connectTimeoutMs
: Math.min(connectTimeoutMs, requestTimeoutMs),
context.signal
)
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'model-request',
requestId,
request: parsedRequest
})
const packet = brokerResponsePacketSchema.parse(
await readPacket(
socket,
AbortSignal.any([
context.signal,
timeoutController.signal
])
)
)
if (packet.requestId !== requestId) {
throw new ModelBridgeBrokerError('transport-failed')
}
if (!packet.ok) {
throw new ModelBridgeBrokerError(packet.error.code)
}
responseHandedOff = true
let deliveryFinalized = false
return {
response: packet.response,
acknowledgeDelivery: async () => {
if (deliveryFinalized) {
throw new ModelBridgeBrokerError('transport-failed')
}
deliveryFinalized = true
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'response-delivered',
requestId
})
const accepted = brokerDeliveryAcceptedPacketSchema.parse(
await readPacket(
socket,
AbortSignal.any([
context.signal,
timeoutController.signal
])
)
)
if (accepted.requestId !== requestId) {
throw new ModelBridgeBrokerError('transport-failed')
}
cleanup()
},
failDelivery: () => {
deliveryFinalized = true
cleanup()
}
}
} catch (error) {
if (context.signal.aborted) {
throw new ModelBridgeBrokerError('request-cancelled')
}
if (timeoutController.signal.aborted) {
throw new ModelBridgeBrokerError('request-timeout')
}
if (error instanceof ModelBridgeBrokerError) {
throw error
}
throw new ModelBridgeBrokerError('transport-failed')
} finally {
if (!responseHandedOff) {
cleanup()
}
}
}
}
type BrokerFailureCode = z.infer<
typeof brokerFailureCodeSchema
>
type FileIdentity = {
dev: bigint
ino: bigint
}
function failureCode(
error: unknown,
signal: AbortSignal
): BrokerFailureCode {
const reason = signal.reason
if (
signal.aborted &&
reason instanceof ModelBridgeBrokerError &&
reason.code === 'request-timeout'
) {
return 'request-timeout'
}
if (signal.aborted) {
return 'request-cancelled'
}
if (error instanceof z.ZodError || error instanceof SyntaxError) {
return 'request-invalid'
}
return 'dispatch-failed'
}
async function writeFailure(
socket: Socket,
requestId: string,
code: BrokerFailureCode
): Promise<void> {
await writePacket(socket, {
version: BROKER_PROTOCOL_VERSION,
type: 'model-response',
requestId,
ok: false,
error: { code }
})
}
async function readPacket(
socket: Socket,
signal: AbortSignal
): Promise<unknown> {
const lengthBytes = await readExactly(socket, 4, signal)
const length = lengthBytes.readUInt32BE(0)
if (length < 2 || length > MAXIMUM_FRAME_BYTES) {
throw new ModelBridgeBrokerError('request-invalid')
}
const contents = await readExactly(socket, length, signal)
const text = new TextDecoder('utf-8', { fatal: true }).decode(
contents
)
return JSON.parse(text) as unknown
}
async function writePacket(
socket: Socket,
value: unknown
): Promise<void> {
const contents = Buffer.from(JSON.stringify(value), 'utf8')
if (
contents.byteLength < 2 ||
contents.byteLength > MAXIMUM_FRAME_BYTES
) {
throw new ModelBridgeBrokerError('request-invalid')
}
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
packet.writeUInt32BE(contents.byteLength, 0)
contents.copy(packet, 4)
await new Promise<void>((resolve, reject) => {
socket.write(packet, (error) => {
if (error === undefined || error === null) {
resolve()
} else {
reject(error)
}
})
})
}
async function readExactly(
socket: Socket,
byteLength: number,
signal: AbortSignal
): Promise<Buffer> {
const chunks: Buffer[] = []
let remaining = byteLength
while (remaining > 0) {
signal.throwIfAborted()
const readableBytes = Math.min(
remaining,
socket.readableLength
)
if (readableBytes > 0) {
const chunk = socket.read(readableBytes) as Buffer | null
if (chunk === null || chunk.byteLength !== readableBytes) {
throw new ModelBridgeBrokerError('transport-failed')
}
chunks.push(chunk)
remaining -= chunk.byteLength
continue
}
if (socket.readableEnded || socket.destroyed) {
throw new ModelBridgeBrokerError('transport-failed')
}
await new Promise<void>((resolve, reject) => {
const onReadable = (): void => {
cleanup()
resolve()
}
const onClose = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('transport-failed'))
}
const onError = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('transport-failed'))
}
const onAbort = (): void => {
cleanup()
reject(signal.reason)
}
const cleanup = (): void => {
socket.off('readable', onReadable)
socket.off('close', onClose)
socket.off('end', onClose)
socket.off('error', onError)
signal.removeEventListener('abort', onAbort)
}
socket.once('readable', onReadable)
socket.once('close', onClose)
socket.once('end', onClose)
socket.once('error', onError)
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
return
}
if (socket.readableLength > 0) {
onReadable()
return
}
if (socket.readableEnded || socket.destroyed) {
onClose()
}
})
}
return Buffer.concat(chunks, byteLength)
}
async function waitForConnection(
socket: Socket,
timeoutMs: number,
signal: AbortSignal
): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined
await new Promise<void>((resolve, reject) => {
const onConnect = (): void => {
cleanup()
resolve()
}
const onError = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('transport-failed'))
}
const onAbort = (): void => {
cleanup()
reject(new ModelBridgeBrokerError('request-cancelled'))
}
const cleanup = (): void => {
if (timeout !== undefined) {
clearTimeout(timeout)
}
socket.off('connect', onConnect)
socket.off('error', onError)
signal.removeEventListener('abort', onAbort)
}
timeout = setTimeout(() => {
cleanup()
reject(new ModelBridgeBrokerError('request-timeout'))
}, timeoutMs)
timeout.unref?.()
socket.once('connect', onConnect)
socket.once('error', onError)
signal.addEventListener('abort', onAbort, { once: true })
})
}
function listenOnUnixSocket(
server: Server,
socketPath: string
): 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(socketPath)
})
}
function removeOwnedStaleSocket(path: string): void {
let stat: Stats
try {
stat = lstatSync(path)
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return
}
throw error
}
assertOwnedSocket(stat, 'Existing model bridge endpoint')
if ((stat.mode & 0o777) !== 0o600) {
throw new Error(
'Refusing to replace an insecure model bridge endpoint'
)
}
unlinkSync(path)
}
function removeSocketIfIdentityMatches(
path: string,
identity: FileIdentity | undefined
): void {
if (identity === undefined) {
return
}
let stat: Stats
try {
stat = lstatSync(path)
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return
}
throw error
}
if (
stat.isSocket() &&
isOwnedByCurrentUser(stat) &&
sameFile(stat, identity)
) {
unlinkSync(path)
}
}
function assertOwnedSocket(stat: Stats, label: string): void {
if (
stat.isSymbolicLink() ||
!stat.isSocket() ||
!isOwnedByCurrentUser(stat)
) {
throw new Error(`${label} is not an owned Unix socket`)
}
}
function assertSameFile(
stat: Stats,
identity: FileIdentity
): void {
if (!sameFile(stat, identity)) {
throw new Error('Model bridge endpoint changed during setup')
}
}
function sameFile(stat: Stats, identity: FileIdentity): boolean {
return (
BigInt(stat.dev) === identity.dev &&
BigInt(stat.ino) === identity.ino
)
}
function fileIdentity(stat: Stats): FileIdentity {
return {
dev: BigInt(stat.dev),
ino: BigInt(stat.ino)
}
}
function isOwnedByCurrentUser(stat: Stats): boolean {
const uid = process.getuid?.()
return uid === undefined || stat.uid === uid
}
function modelBridgeBrokerErrorMessage(
code: ModelBridgeBrokerError['code']
): string {
switch (code) {
case 'request-invalid':
return 'Model bridge request was rejected'
case 'request-timeout':
return 'Model bridge request timed out'
case 'request-cancelled':
return 'Model bridge request was cancelled'
case 'dispatch-failed':
return 'Model bridge dispatch failed'
case 'transport-failed':
return 'Model bridge transport failed'
}
}
function normalizeExchangeResult(
value: ModelBridgeExchangeResult | RemoteModelGatewayResponse
): 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
}
}