Files
goodbuddy/src/agent-daemon/private-endpoint.ts
T
mesalogo c8db340cc7 fix: prevent runtime permission stalls and Agent reconnect failures
Execute could stall on workspace-external access or native child-session
permissions, while structured questions could be lost or answered incorrectly.
Route owned OpenCode descendant interactions, correct Continue question and
custom-answer handling, and resolve DSH Execute ACP permissions without a
second approval. Ask remains read-only and unrelated sessions stay isolated.

Restore the required DSH session projection service. Reuse offline OpenCode
configuration by plugin version, remove unused npm shims from remote payloads,
normalize dependency modes, and accept real compound descriptors up to 4 MiB.
Fix intermittent Agent attach rejection and closed-handshake waits.

Add focused and real-Runtime regressions, document interaction boundaries and
validation evidence, and include the existing application/tool navigation
design drafts. Those navigation changes are design-only, not implemented UI.

Validation: full suite 3597 passed, 0 failed, 61 skipped across 338 files;
typecheck, lint, release-note verification, whitespace and relative-link checks
passed. Current-source Linux x64 installation, Ask/Execute, native subagent
external writes, reconnect and stop/bootstrap passed with 10 completed real
model calls, 13 native endpoint tests and 20 consecutive handshakes.
System sleep/wake and release CI packaging remain unverified.

Release note: 修复 Execute 访问工作区外目录或运行原生子代理时卡在权限等待的问题,并恢复 OpenCode 与 Continue 结构化问答的正确转交。修复远程 Agent 安装与重连失败;Ask 仍保持只读,任意终端密码或业务确认不会自动代答。
2026-09-08 11:34:45 +08:00

655 lines
19 KiB
TypeScript

import {
chmodSync,
lstatSync
} from 'node:fs'
import { randomBytes } from 'node:crypto'
import {
createConnection,
createServer,
type Server,
type Socket
} from 'node:net'
import { dirname } from 'node:path'
import { z } from 'zod'
import {
agentIdentifierSchema,
attachPrefaceSchema,
attachWelcomeSchema,
protocolVersionSchema,
sha256DigestSchema,
type AttachPreface,
type AttachWelcome
} from '../shared/agent-protocol'
import { ControllerRegistry, type ControllerLease } from './controller-registry'
import {
InstallationChallengeVerifier
} from './installation-challenge'
import {
assertAbsoluteManagedPath,
ensurePrivateDirectory
} from './managed-paths'
import { AgentUnsupportedError } from './errors'
import { closeServer, settleWithin } from './async-utils'
const MAXIMUM_HANDSHAKE_BYTES = 4096
const DEFAULT_MAXIMUM_CONNECTIONS = 64
const ENDPOINT_CLOSE_TIMEOUT_MS = 2_000
const challengePacketSchema = z
.object({
type: z.literal('challenge'),
version: z.literal(1),
serverNonce: agentIdentifierSchema,
expiresAt: z.number().int().safe().nonnegative()
})
.strict()
const challengeResponsePacketSchema = z
.object({
type: z.literal('response'),
version: z.literal(1),
controllerId: agentIdentifierSchema,
clientNonce: agentIdentifierSchema,
protocol: protocolVersionSchema,
// A SHA-256 HMAC is canonical Base64URL, not an application identifier.
response: z.string().regex(/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/u)
})
.strict()
export type PeerIdentity = {
uid: number
pid?: number
}
export interface UnixPeerIdentityProvider {
getPeerIdentity(socket: Socket): Promise<PeerIdentity>
}
export type AuthenticatedAttach = {
socket: Socket
controller: ControllerLease
peer: PeerIdentity
}
export class PrivateEndpoint {
readonly #socketPath: string
readonly #peerIdentity: UnixPeerIdentityProvider
readonly #challenge: InstallationChallengeVerifier
readonly #controllers: ControllerRegistry
readonly #onAttach: (attach: AuthenticatedAttach) => void | Promise<void>
readonly #onConnectionFailure?: (error: unknown) => void
readonly #installationId: string
readonly #binaryDigest: string
readonly #daemonBootId: string
readonly #protocol: AttachPreface['protocol']
readonly #maximumConnections: number
readonly #sockets = new Set<Socket>()
#server?: Server
#closing = false
#closePromise?: Promise<void>
constructor(options: {
socketPath: string
peerIdentity: UnixPeerIdentityProvider
challenge: InstallationChallengeVerifier
controllers: ControllerRegistry
onAttach: (attach: AuthenticatedAttach) => void | Promise<void>
onConnectionFailure?: (error: unknown) => void
installationId: string
binaryDigest: string
daemonBootId: string
protocol: AttachPreface['protocol']
maximumConnections?: number
}) {
this.#socketPath = assertAbsoluteManagedPath(options.socketPath)
this.#peerIdentity = options.peerIdentity
this.#challenge = options.challenge
this.#controllers = options.controllers
this.#onAttach = options.onAttach
this.#onConnectionFailure = options.onConnectionFailure
this.#installationId = agentIdentifierSchema.parse(options.installationId)
this.#binaryDigest = sha256DigestSchema.parse(options.binaryDigest)
this.#daemonBootId = agentIdentifierSchema.parse(options.daemonBootId)
this.#protocol = protocolVersionSchema.parse(options.protocol)
this.#maximumConnections =
options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS
if (
!Number.isSafeInteger(this.#maximumConnections) ||
this.#maximumConnections < 1 ||
this.#maximumConnections > 1024
) {
throw new RangeError('Invalid private endpoint connection limit')
}
}
async listen(): Promise<void> {
if (process.platform === 'win32') {
throw new AgentUnsupportedError(
'Unix private endpoints are unavailable on Windows',
'platform-incompatible'
)
}
ensurePrivateDirectory(dirname(this.#socketPath), { create: false })
if (this.#server !== undefined || this.#closing) {
throw new Error('Private endpoint is already active')
}
this.#server = createServer((socket) => {
if (
this.#closing ||
this.#sockets.size >= this.#maximumConnections
) {
socket.destroy()
return
}
this.#sockets.add(socket)
socket.once('close', () => {
this.#sockets.delete(socket)
})
void this.#authenticate(socket).catch((error: unknown) => {
this.#onConnectionFailure?.(error)
socket.destroy()
})
})
await new Promise<void>((resolve, reject) => {
const server = this.#server
if (server === undefined) {
reject(new Error('Endpoint server was not created'))
return
}
server.once('error', reject)
server.listen(this.#socketPath, () => {
server.off('error', reject)
resolve()
})
})
chmodSync(this.#socketPath, 0o600)
const stat = lstatSync(this.#socketPath, { bigint: true })
if (!stat.isSocket() || (stat.mode & 0o177n) !== 0n) {
await this.close()
throw new Error('Private endpoint mode verification failed')
}
const uid = process.getuid?.()
if (uid !== undefined && stat.uid !== BigInt(uid)) {
await this.close()
throw new Error('Private endpoint owner verification failed')
}
}
async close(): Promise<void> {
const activeClose = this.#closePromise
if (activeClose !== undefined) {
await activeClose
return
}
const closePromise = this.#closeEndpoint()
this.#closePromise = closePromise
try {
await closePromise
} finally {
if (this.#closePromise === closePromise) {
this.#closePromise = undefined
}
}
}
async #closeEndpoint(): Promise<void> {
const server = this.#server
this.#server = undefined
this.#closing = true
try {
const socketClosures = [...this.#sockets].map(waitForSocketClose)
for (const socket of this.#sockets) {
socket.destroy()
}
let serverCloseError: unknown
const serverClosure =
server === undefined
? Promise.resolve()
: closeServer(server).catch((error: unknown) => {
serverCloseError = error
})
await settleWithin(
Promise.allSettled([...socketClosures, serverClosure]),
ENDPOINT_CLOSE_TIMEOUT_MS
)
if (serverCloseError !== undefined) {
throw serverCloseError
}
} finally {
this.#sockets.clear()
this.#closing = false
}
}
async #authenticate(socket: Socket): Promise<void> {
socket.pause()
socket.setTimeout(30_000, () => socket.destroy())
const peer = await this.#peerIdentity.getPeerIdentity(socket)
this.#assertOpen(socket)
const expectedUid = process.getuid?.()
if (expectedUid === undefined || peer.uid !== expectedUid) {
throw new Error('Attach peer is not the current user')
}
const challenge = this.#challenge.issue()
this.#assertOpen(socket)
await writePacket(socket, {
type: 'challenge',
version: 1,
...challenge
})
const response = challengeResponsePacketSchema.parse(
await readPacket(socket)
)
this.#assertOpen(socket)
const controllerId = response.controllerId
if (
!this.#challenge.verify({
serverNonce: challenge.serverNonce,
clientNonce: response.clientNonce,
controllerId,
response: response.response
})
) {
throw new Error('Installation challenge failed')
}
if (
response.protocol.major !== this.#protocol.major ||
response.protocol.minor < this.#protocol.minor
) {
throw new Error('Attach protocol version is incompatible')
}
this.#assertOpen(socket)
const controller = this.#controllers.attach(controllerId)
this.#assertOpen(socket)
await writePacket(socket, attachWelcomeSchema.parse({
type: 'goodbuddy-agent-welcome',
protocol: this.#protocol,
connectionId: controller.connectionId,
generation: controller.generation,
installationId: this.#installationId,
binaryDigest: this.#binaryDigest,
daemonBootId: this.#daemonBootId,
serverNonce: challenge.serverNonce
}))
this.#assertOpen(socket)
socket.setTimeout(0)
await this.#onAttach({ socket, controller, peer })
this.#assertOpen(socket)
socket.resume()
}
#assertOpen(socket: Socket): void {
if (
this.#closing ||
this.#server === undefined ||
!this.#sockets.has(socket) ||
socket.destroyed
) {
throw new Error('Private endpoint is closing')
}
}
}
export async function attachRelay(options: {
socketPath: string
secret: Uint8Array
input: NodeJS.ReadableStream
output: NodeJS.WritableStream
ensureEndpoint?: (
reason: 'absent' | 'stale'
) => void | Promise<void>
onWelcome?: (welcome: AttachWelcome) => void
}): Promise<void> {
const preface = await readRelayPreface(options.input)
if (process.platform === 'win32') {
throw new AgentUnsupportedError(
'Unix attach is unavailable on Windows',
'platform-incompatible'
)
}
const socketPath = assertAbsoluteManagedPath(options.socketPath)
let socket = createConnection(socketPath)
let welcome: AttachWelcome
try {
try {
await waitForConnection(socket)
} catch (error) {
socket.destroy()
const reason = endpointAbsenceReason(error)
if (reason === undefined || options.ensureEndpoint === undefined) {
throw error
}
await options.ensureEndpoint(reason)
socket = createConnection(socketPath)
await waitForConnection(socket)
}
socket.pause()
const challengeResult = challengePacketSchema.safeParse(
await readPacket(socket)
)
if (!challengeResult.success) {
throw new Error('Daemon returned an invalid challenge')
}
const challenge = challengeResult.data
const verifier = new InstallationChallengeVerifier(options.secret)
await writePacket(socket, {
type: 'response',
version: 1,
controllerId: preface.controllerId,
clientNonce: preface.clientNonce,
protocol: preface.protocol,
response: verifier.createResponse({
serverNonce: challenge.serverNonce,
clientNonce: preface.clientNonce,
controllerId: preface.controllerId
})
})
const welcomeResult = attachWelcomeSchema.safeParse(
await readPacket(socket)
)
if (!welcomeResult.success) {
throw new Error('Daemon rejected attach authentication')
}
welcome = welcomeResult.data
if (
welcome.protocol.major !== preface.protocol.major ||
welcome.protocol.minor > preface.protocol.minor ||
welcome.serverNonce !== challenge.serverNonce
) {
throw new Error('Daemon returned an incompatible attach welcome')
}
} catch (error) {
socket.destroy()
throw error
}
try {
await writeStreamPacket(options.output, welcome)
options.onWelcome?.(welcome)
} catch (error) {
socket.destroy()
throw error
}
socket.resume()
options.input.pipe(socket)
socket.pipe(options.output)
await new Promise<void>((resolve, reject) => {
socket.once('close', resolve)
socket.once('error', reject)
options.input.once('error', reject)
})
}
export async function probeAuthenticatedEndpoint(options: {
socketPath: string
secret: Buffer
installationId: string
binaryDigest: string
protocol: AttachPreface['protocol']
}): Promise<AttachWelcome> {
if (process.platform === 'win32') {
throw new AgentUnsupportedError(
'Unix attach is unavailable on Windows',
'platform-incompatible'
)
}
const socket = createConnection(
assertAbsoluteManagedPath(options.socketPath)
)
try {
await waitForConnection(socket)
socket.pause()
const challenge = challengePacketSchema.parse(await readPacket(socket))
const verifier = new InstallationChallengeVerifier(options.secret)
const controllerId = 'lifecycle-health'
const clientNonce = `health-${randomBytes(24).toString('base64url')}`
await writePacket(socket, {
type: 'response',
version: 1,
controllerId,
clientNonce,
protocol: options.protocol,
response: verifier.createResponse({
serverNonce: challenge.serverNonce,
clientNonce,
controllerId
})
})
const welcome = attachWelcomeSchema.parse(await readPacket(socket))
if (
welcome.serverNonce !== challenge.serverNonce ||
welcome.installationId !== options.installationId ||
welcome.binaryDigest !== options.binaryDigest ||
welcome.protocol.major !== options.protocol.major ||
welcome.protocol.minor > options.protocol.minor
) {
throw new Error(
'Detached Agent endpoint identity does not match its installation'
)
}
return welcome
} finally {
socket.destroy()
}
}
function waitForConnection(socket: Socket): Promise<void> {
return new Promise<void>((resolve, reject) => {
const connected = (): void => {
socket.off('error', failed)
resolve()
}
const failed = (error: Error): void => {
socket.off('connect', connected)
reject(error)
}
socket.once('connect', connected)
socket.once('error', failed)
})
}
function endpointAbsenceReason(
error: unknown
): 'absent' | 'stale' | undefined {
if (!isNodeError(error)) {
return undefined
}
if (error.code === 'ENOENT') {
return 'absent'
}
if (error.code === 'ECONNREFUSED') {
return 'stale'
}
return undefined
}
async function readRelayPreface(
input: NodeJS.ReadableStream
): Promise<Pick<
AttachPreface,
'protocol' | 'controllerId' | 'clientNonce'
>> {
const parsed = attachPrefaceSchema.parse(await readStreamPacket(input))
return {
protocol: parsed.protocol,
controllerId: parsed.controllerId,
clientNonce: parsed.clientNonce
}
}
async function readStreamPacket(
input: NodeJS.ReadableStream
): Promise<Record<string, unknown>> {
const stream = input as NodeJS.ReadableStream & {
read(size?: number): Buffer | null
}
const lengthBytes = await readExactlyFromStream(stream, 4)
const length = lengthBytes.readUInt32BE(0)
if (length < 2 || length > MAXIMUM_HANDSHAKE_BYTES) {
throw new Error('Attach relay preface is oversized')
}
const contents = await readExactlyFromStream(stream, length)
const value: unknown = JSON.parse(contents.toString('utf8'))
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Attach relay preface must be an object')
}
return value as Record<string, unknown>
}
async function writeStreamPacket(
output: NodeJS.WritableStream,
value: Record<string, unknown>
): Promise<void> {
const contents = Buffer.from(JSON.stringify(value), 'utf8')
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
packet.writeUInt32BE(contents.byteLength, 0)
contents.copy(packet, 4)
await new Promise<void>((resolve, reject) => {
output.write(packet, (error?: Error | null) => {
if (error === null || error === undefined) {
resolve()
} else {
reject(error)
}
})
})
}
async function readPacket(socket: Socket): Promise<Record<string, unknown>> {
const lengthBytes = await readExactly(socket, 4)
const length = lengthBytes.readUInt32BE(0)
if (length < 2 || length > MAXIMUM_HANDSHAKE_BYTES) {
throw new Error('Attach handshake packet is oversized')
}
const contents = await readExactly(socket, length)
const value: unknown = JSON.parse(contents.toString('utf8'))
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Attach handshake packet must be an object')
}
return value as Record<string, unknown>
}
async function writePacket(
socket: Socket,
value: Record<string, unknown>
): Promise<void> {
const contents = Buffer.from(JSON.stringify(value), 'utf8')
if (contents.byteLength > MAXIMUM_HANDSHAKE_BYTES) {
throw new Error('Attach handshake packet is oversized')
}
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 === null || error === undefined) {
resolve()
} else {
reject(error)
}
})
})
}
async function readExactly(socket: Socket, byteLength: number): Promise<Buffer> {
const chunks: Buffer[] = []
let remaining = byteLength
while (remaining > 0) {
const chunk = socket.read(remaining) as Buffer | null
if (chunk !== null) {
chunks.push(chunk)
remaining -= chunk.byteLength
continue
}
if (socket.destroyed || socket.readableEnded) {
throw new Error('Attach socket closed during handshake')
}
await new Promise<void>((resolve, reject) => {
const onReadable = (): void => {
cleanup()
resolve()
}
const onEnd = (): void => {
cleanup()
reject(new Error('Attach socket closed during handshake'))
}
const onError = (error: Error): void => {
cleanup()
reject(error)
}
const cleanup = (): void => {
socket.off('readable', onReadable)
socket.off('end', onEnd)
socket.off('close', onEnd)
socket.off('error', onError)
}
socket.once('readable', onReadable)
socket.once('end', onEnd)
socket.once('close', onEnd)
socket.once('error', onError)
})
}
return Buffer.concat(chunks, byteLength)
}
async function readExactlyFromStream(
stream: NodeJS.ReadableStream & {
read(size?: number): Buffer | null
destroyed?: boolean
readableEnded?: boolean
},
byteLength: number
): Promise<Buffer> {
const chunks: Buffer[] = []
let remaining = byteLength
while (remaining > 0) {
const chunk = stream.read(remaining)
if (chunk !== null) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
chunks.push(bytes)
remaining -= bytes.byteLength
continue
}
if (stream.destroyed || stream.readableEnded) {
throw new Error('Attach relay input closed during preface')
}
await new Promise<void>((resolve, reject) => {
const onReadable = (): void => {
cleanup()
resolve()
}
const onEnd = (): void => {
cleanup()
reject(new Error('Attach relay input closed during preface'))
}
const onError = (error: Error): void => {
cleanup()
reject(error)
}
const cleanup = (): void => {
stream.removeListener('readable', onReadable)
stream.removeListener('end', onEnd)
stream.removeListener('close', onEnd)
stream.removeListener('error', onError)
}
stream.once('readable', onReadable)
stream.once('end', onEnd)
stream.once('close', onEnd)
stream.once('error', onError)
})
}
return Buffer.concat(chunks, byteLength)
}
function waitForSocketClose(socket: Socket): Promise<void> {
if (socket.closed) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
socket.once('close', resolve)
})
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error
}