fix: prevent agent communication bounds from hiding CI regressions

Tighten the UDS auth, framing, and response-reader boundaries while keeping the AgentSummary lifecycle covered so Codecov and CI fail on real regressions instead of missing coverage. The poorMode settings mock mirrors unrelated real settings defaults to avoid Bun mock retention changing later permission tests.

Constraint: PR #369 must fix Codecov/CI precisely without warning suppression, fallback masking, or mock pollution

Rejected: Delete AgentSummary lifecycle coverage | would hide Codecov loss and stale-summary behavior

Rejected: Store inline UDS rejection in a hidden input sentinel | cloned observable inputs can drop it and bypass rejection

Rejected: Ignore malformed UDS frames until timeout | leaves client slots and SendMessage calls open to exhaustion

Confidence: high

Scope-risk: moderate

Directive: Keep empty #token= markers rejected; do not require a non-empty token value in hasInlineUdsToken

Tested: bun test packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts src/utils/__tests__/udsMessaging.test.ts src/utils/__tests__/udsResponseReader.test.ts src/utils/__tests__/ndjsonFramer.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage

Tested: bun run test:all

Tested: bun audit

Tested: bun run build

Tested: bun run build:vite

Not-tested: GitHub-hosted Codecov upload until pushed PR checks rerun
This commit is contained in:
unraid
2026-04-27 12:50:08 +08:00
parent ee0d788e58
commit 379928fa10
21 changed files with 1725 additions and 335 deletions

View File

@@ -88,4 +88,66 @@ describe('attachNdjsonFramer', () => {
expect(errors[0]?.message).toContain('NDJSON frame exceeded')
expect(socket.destroyed).toBe(true)
})
test('lets callers own oversized-frame shutdown when configured', () => {
const socket = createTestSocket()
const errors: Error[] = []
attachNdjsonFramer(
socket,
() => undefined,
text => JSON.parse(text) as unknown,
{
maxFrameBytes: 8,
onFrameError: error => errors.push(error),
destroyOnFrameError: false,
},
)
socket.emitData(Buffer.from('{"long":true}\n'))
expect(errors[0]?.message).toContain('NDJSON frame exceeded')
expect(socket.destroyed).toBe(false)
})
test('reports malformed non-empty frames without changing default compatibility', () => {
const socket = createTestSocket()
const messages: unknown[] = []
const errors: Error[] = []
attachNdjsonFramer(
socket,
msg => messages.push(msg),
text => JSON.parse(text) as unknown,
{
onInvalidFrame: error => errors.push(error),
},
)
socket.emitData(Buffer.from('{not-json\n'))
expect(messages).toEqual([])
expect(errors).toHaveLength(1)
expect(socket.destroyed).toBe(false)
})
test('destroys malformed frames when configured by the caller', () => {
const socket = createTestSocket()
const errors: Error[] = []
attachNdjsonFramer(
socket,
() => undefined,
text => JSON.parse(text) as unknown,
{
destroyOnInvalidFrame: true,
onInvalidFrame: error => errors.push(error),
},
)
socket.emitData(Buffer.from('{not-json\n'))
expect(errors).toHaveLength(1)
expect(socket.destroyed).toBe(true)
})
})

View File

@@ -3,7 +3,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import type { Message } from '../../types/message.js'
import type { Message } from 'src/types/message.js'
import {
compactMailboxMessages,
getLastPeerDmSummary,
@@ -13,13 +13,14 @@ import {
markMessagesAsRead,
markMessagesAsReadByPredicate,
MAX_MAILBOX_MESSAGE_TEXT_BYTES,
MAX_MAILBOX_FILE_BYTES,
MAX_MAILBOX_MESSAGES,
MAX_READ_MAILBOX_MESSAGES,
MAX_UNREAD_PROTOCOL_MAILBOX_MESSAGES,
readMailbox,
type TeammateMessage,
writeToMailbox,
} from '../teammateMailbox.js'
} from 'src/utils/teammateMailbox.js'
let tempHome = ''
let previousConfigDir: string | undefined
@@ -55,21 +56,6 @@ async function readRawMailbox(
return JSON.parse(content) as TeammateMessage[]
}
beforeEach(() => {
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
tempHome = mkdtempSync(join(tmpdir(), 'teammate-mailbox-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterEach(async () => {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
await rm(tempHome, { recursive: true, force: true })
})
describe('compactMailboxMessages', () => {
test('prioritizes unread messages and keeps only recent read history', () => {
const compacted = compactMailboxMessages(
@@ -175,9 +161,46 @@ describe('compactMailboxMessages', () => {
expect(compacted.length).toBeLessThan(20)
expect(compacted.at(-1)?.text).toContain('msg-19')
})
test('returns an empty mailbox when even one message exceeds retained budget', () => {
const compacted = compactMailboxMessages([message('too-large', false)], {
maxMessages: 10,
maxReadMessages: 0,
maxRetainedBytes: 1,
})
expect(compacted).toEqual([])
})
test('returns an empty mailbox when all retention lanes are disabled', () => {
const compacted = compactMailboxMessages([message('unread', false)], {
maxMessages: 0,
maxReadMessages: 0,
maxUnreadProtocolMessages: 0,
maxRetainedBytes: 1_000,
})
expect(compacted).toEqual([])
})
})
describe('teammate mailbox retention', () => {
beforeEach(() => {
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
tempHome = mkdtempSync(join(tmpdir(), 'teammate-mailbox-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterEach(async () => {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
await rm(tempHome, { recursive: true, force: true })
tempHome = ''
})
test('writeToMailbox compacts oversized unread inbox files', async () => {
const existing = Array.from(
{ length: MAX_MAILBOX_MESSAGES + 20 },
@@ -319,6 +342,23 @@ describe('teammate mailbox retention', () => {
expect(await readFile(inboxPath, 'utf-8')).toBe('{not-json')
})
test('writeToMailbox rejects when the inbox path is already a directory', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(inboxPath, { recursive: true })
await expect(
writeToMailbox(
'worker',
{
from: 'team-lead',
text: 'new',
timestamp: new Date(5).toISOString(),
},
'alpha',
),
).rejects.toThrow()
})
test('readMailbox fails closed on corrupt mailbox content', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
@@ -326,6 +366,76 @@ describe('teammate mailbox retention', () => {
await expect(readMailbox('worker', 'alpha')).rejects.toThrow()
})
test('readMailbox rejects non-array mailbox files', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, JSON.stringify({ text: 'not an array' }), 'utf-8')
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
'expected message array',
)
})
test('readMailbox rejects malformed stored message shapes', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(
inboxPath,
JSON.stringify([{ from: 'lead', text: 'missing timestamp' }]),
'utf-8',
)
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
'Invalid mailbox message shape',
)
})
test('readMailbox rejects non-object stored messages', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, JSON.stringify(['not an object']), 'utf-8')
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
'expected object',
)
})
test('readMailbox rejects oversized mailbox files before parsing', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, `[${' '.repeat(MAX_MAILBOX_FILE_BYTES)}]`, 'utf-8')
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
'Mailbox file exceeds',
)
})
test('markMessageAsReadByIdentity returns false for missing mailbox files', async () => {
await expect(
markMessageAsReadByIdentity('worker', 'alpha', message('absent', false)),
).resolves.toBe(false)
})
test('markMessageAsReadByIdentity returns false when the expected message moved out', async () => {
await seedMailbox('worker', 'alpha', [message('other', false)])
await expect(
markMessageAsReadByIdentity('worker', 'alpha', message('missing', false)),
).resolves.toBe(false)
expect((await readRawMailbox('worker', 'alpha'))[0]?.read).toBe(false)
})
test('markMessageAsReadByIdentity returns false on corrupt mailbox content', async () => {
const inboxPath = getInboxPath('worker', 'alpha')
await mkdir(dirname(inboxPath), { recursive: true })
await writeFile(inboxPath, '{not-json', 'utf-8')
await expect(
markMessageAsReadByIdentity('worker', 'alpha', message('missing', false)),
).resolves.toBe(false)
})
})
describe('getLastPeerDmSummary', () => {

View File

@@ -3,24 +3,31 @@ import {
chmod,
mkdir,
mkdtemp,
readdir,
rm,
stat,
symlink,
unlink,
writeFile,
} from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { createConnection, createServer } from 'node:net'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import {
drainInbox,
getDefaultUdsSocketPath,
MAX_UDS_INBOX_ENTRIES,
MAX_UDS_INBOX_BYTES,
MAX_UDS_FRAME_BYTES,
MAX_UDS_CLIENTS,
formatUdsAddress,
parseUdsTarget,
sendUdsMessage,
setOnEnqueue,
startUdsMessaging,
stopUdsMessaging,
UDS_AUTH_TIMEOUT_MS,
} from '../udsMessaging.js'
let previousConfigDir: string | undefined
@@ -192,7 +199,7 @@ describe('UDS inbox retention', () => {
try {
const { isPeerAlive } = await import('../udsClient.js')
expect(await isPeerAlive(path)).toBe(false)
expect(await isPeerAlive(path, 3_000, 'test-token')).toBe(false)
} finally {
await closeServer(receiver)
if (process.platform !== 'win32') {
@@ -210,6 +217,29 @@ describe('UDS inbox retention', () => {
)
})
test('udsClient send reports connection failures without leaking token state', async () => {
const path = socketPath('uds-client-connect-error')
const capabilityDir = join(tempConfigDir, 'messaging-capabilities')
const capabilityName = `${createHash('sha256').update(path).digest('hex')}.json`
await mkdir(capabilityDir, { recursive: true, mode: 0o700 })
await writeFile(
join(capabilityDir, capabilityName),
JSON.stringify({ socketPath: path, authToken: 'test-token' }),
'utf-8',
)
const { sendToUdsSocket } = await import('../udsClient.js')
await expect(sendToUdsSocket(path, 'hello')).rejects.toThrow(
'Failed to connect to peer',
)
})
test('sendUdsMessage fails closed before connecting without an auth token', async () => {
await expect(
sendUdsMessage(socketPath('no-auth-token'), { type: 'text', data: 'x' }),
).rejects.toThrow('without auth token')
})
test('drained entries never expose the UDS auth token', async () => {
const path = socketPath('strip-token')
await startUdsMessaging(path, { isExplicit: true })
@@ -232,6 +262,7 @@ describe('UDS inbox retention', () => {
await startUdsMessaging(path, { isExplicit: true })
const response = await new Promise<string>((resolve, reject) => {
let responseText = ''
const conn = createConnection(path, () => {
conn.write(`${JSON.stringify({ type: 'text', data: 'bad' })}\n`)
})
@@ -242,10 +273,10 @@ describe('UDS inbox retention', () => {
conn.on('data', chunk => {
const text = chunk.toString('utf-8')
if (text.includes('\n')) {
conn.end()
resolve(text)
responseText = text
}
})
conn.on('close', () => resolve(responseText))
conn.on('error', reject)
})
@@ -253,6 +284,56 @@ describe('UDS inbox retention', () => {
expect(drainInbox()).toEqual([])
})
test('disconnects malformed JSON clients without enqueueing inbox work', async () => {
const path = socketPath('malformed-client')
await startUdsMessaging(path, { isExplicit: true })
const response = await new Promise<string>((resolve, reject) => {
let responseText = ''
const conn = createConnection(path, () => {
conn.write('{not-json\n')
})
conn.setTimeout(5_000, () => {
conn.destroy()
reject(new Error('Timed out waiting for malformed frame close'))
})
conn.on('data', chunk => {
responseText += chunk.toString('utf-8')
})
conn.on('close', () => resolve(responseText))
conn.on('error', reject)
})
const parsed = JSON.parse(response)
expect(parsed.type).toBe('error')
expect(parsed.data).toBe('invalid frame')
expect(drainInbox()).toEqual([])
})
test('disconnects idle unauthenticated clients', async () => {
const path = socketPath('idle-client')
await startUdsMessaging(path, { isExplicit: true })
const response = await new Promise<string>((resolve, reject) => {
let responseText = ''
const conn = createConnection(path)
conn.setTimeout(UDS_AUTH_TIMEOUT_MS + 2_000, () => {
conn.destroy()
reject(new Error('Timed out waiting for auth timeout close'))
})
conn.on('data', chunk => {
responseText += chunk.toString('utf-8')
})
conn.on('close', () => resolve(responseText))
conn.on('error', reject)
})
const parsed = JSON.parse(response)
expect(parsed.type).toBe('error')
expect(parsed.data).toBe('authentication timeout')
expect(drainInbox()).toEqual([])
})
test('destroys oversized frames before enqueueing inbox work', async () => {
const path = socketPath('oversized')
await startUdsMessaging(path, { isExplicit: true })
@@ -272,6 +353,14 @@ describe('UDS inbox retention', () => {
expect(drainInbox()).toEqual([])
})
test('default socket path is regenerated after stop', async () => {
const firstPath = getDefaultUdsSocketPath()
await startUdsMessaging(firstPath)
await stopUdsMessaging()
expect(getDefaultUdsSocketPath()).not.toBe(firstPath)
})
test('rejects oversized receiver responses before retaining them', async () => {
const path = socketPath('oversized-response')
if (process.platform !== 'win32') {
@@ -303,9 +392,71 @@ describe('UDS inbox retention', () => {
}
})
test('rejects closed receiver responses without waiting for timeout', async () => {
const path = socketPath('closed-response')
if (process.platform !== 'win32') {
await mkdir(dirname(path), { recursive: true })
}
const receiver = createServer(socket => {
socket.end()
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
try {
await expect(
sendUdsMessage(
path,
{ type: 'text', data: 'hello' },
{ authToken: 'test-token' },
),
).rejects.toThrow('before response')
} finally {
await closeServer(receiver)
if (process.platform !== 'win32') {
await unlink(path).catch(() => undefined)
}
}
})
test('rejects malformed receiver responses without waiting for timeout', async () => {
const path = socketPath('malformed-response')
if (process.platform !== 'win32') {
await mkdir(dirname(path), { recursive: true })
}
const receiver = createServer(socket => {
socket.on('data', () => {
socket.write('{not-json\n')
})
})
await new Promise<void>((resolve, reject) => {
receiver.on('error', reject)
receiver.listen(path, () => resolve())
})
try {
await expect(
sendUdsMessage(
path,
{ type: 'text', data: 'hello' },
{ authToken: 'test-token' },
),
).rejects.toThrow('Invalid UDS response frame')
} finally {
await closeServer(receiver)
if (process.platform !== 'win32') {
await unlink(path).catch(() => undefined)
}
}
})
test('rejects inline auth token UDS targets instead of parsing them', async () => {
const path = socketPath('inline-token')
expect(formatUdsAddress(path)).toBe(`uds:${path}`)
const targetWithToken = `${path}#token=secret`
expect(() => parseUdsTarget(targetWithToken)).toThrow('inline auth token')
try {
@@ -320,6 +471,23 @@ describe('UDS inbox retention', () => {
)
})
test('fails closed and cleans temp files when capability target is occupied', async () => {
const path = socketPath('capability-target-dir')
const capabilityDir = join(tempConfigDir, 'messaging-capabilities')
const capabilityName = `${createHash('sha256').update(path).digest('hex')}.json`
await mkdir(join(capabilityDir, capabilityName), {
recursive: true,
mode: 0o700,
})
await expect(
startUdsMessaging(path, { isExplicit: true }),
).rejects.toThrow()
expect(process.env.CLAUDE_CODE_MESSAGING_SOCKET).toBeUndefined()
expect(await readdir(capabilityDir)).toEqual([capabilityName])
})
if (process.platform !== 'win32') {
test('creates the listening socket with owner-only permissions', async () => {
const path = socketPath('socket-mode')
@@ -341,9 +509,11 @@ describe('UDS inbox retention', () => {
await chmod(capabilityDir, 0o755)
try {
const path = socketPath('broad-capdir')
await expect(
startUdsMessaging(socketPath('broad-capdir'), { isExplicit: true }),
startUdsMessaging(path, { isExplicit: true }),
).rejects.toThrow('permissions are too broad')
await expect(stat(path)).rejects.toThrow()
} finally {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
@@ -397,5 +567,65 @@ describe('UDS inbox retention', () => {
await rm(parent, { recursive: true, force: true })
}
})
test('fails closed when an explicit socket parent is a file', async () => {
const parentFile = join(
tmpdir(),
`uds-socket-parent-file-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
await writeFile(parentFile, 'not a directory', 'utf-8')
try {
await expect(
startUdsMessaging(join(parentFile, 'messaging.sock'), {
isExplicit: true,
}),
).rejects.toThrow('socket parent is not a directory')
} finally {
await rm(parentFile, { force: true })
}
})
test('stop tolerates an already removed socket path', async () => {
const path = socketPath('already-removed')
await startUdsMessaging(path, { isExplicit: true })
await unlink(path)
await stopUdsMessaging()
expect(process.env.CLAUDE_CODE_MESSAGING_SOCKET).toBeUndefined()
})
test('rejects clients over the configured connection cap', async () => {
const path = socketPath('client-cap')
await startUdsMessaging(path, { isExplicit: true })
const sockets: ReturnType<typeof createConnection>[] = []
try {
for (let i = 0; i < MAX_UDS_CLIENTS; i++) {
const socket = await new Promise<ReturnType<typeof createConnection>>(
(resolve, reject) => {
const conn = createConnection(path, () => resolve(conn))
conn.on('error', reject)
},
)
sockets.push(socket)
}
await new Promise<void>((resolve, reject) => {
const extra = createConnection(path)
extra.on('close', () => resolve())
extra.on('error', reject)
extra.setTimeout(5_000, () => {
extra.destroy()
reject(new Error('Timed out waiting for client cap close'))
})
})
} finally {
for (const socket of sockets) {
socket.destroy()
}
}
})
}
})

View File

@@ -0,0 +1,218 @@
import { describe, expect, test } from 'bun:test'
import { EventEmitter } from 'node:events'
import type { Socket } from 'node:net'
import { attachUdsResponseReader } from '../udsResponseReader.js'
class FakeSocket extends EventEmitter {
destroyed = false
ended = false
destroy(): this {
this.destroyed = true
this.emit('close', true)
return this
}
end(): this {
this.ended = true
this.emit('close', false)
return this
}
emitData(chunk: Buffer): void {
this.emit('data', chunk)
}
}
function asSocket(socket: FakeSocket): Socket {
return socket as unknown as Socket
}
describe('attachUdsResponseReader', () => {
test('tracks byte limits across split multibyte response chunks', () => {
const socket = new FakeSocket()
let settled = false
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settled = true
settledError = error
},
})
const multibyte = String.fromCodePoint(0x20ac)
const frame = Buffer.from(
JSON.stringify({ type: 'response', data: `ok ${multibyte}` }) + '\n',
'utf8',
)
const multibyteStart = frame.indexOf(Buffer.from(multibyte, 'utf8')[0])
socket.emitData(frame.subarray(0, multibyteStart + 1))
expect(settled).toBe(false)
socket.emitData(frame.subarray(multibyteStart + 1))
expect(settled).toBe(true)
expect(settledError).toBeUndefined()
expect(socket.ended).toBe(true)
})
test('rejects malformed response frames immediately', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
})
socket.emitData(Buffer.from('{bad-json}\n'))
expect(settledError?.message).toBe('Invalid UDS response frame')
expect(socket.destroyed).toBe(true)
})
test('skips blank frames before a valid response', () => {
const socket = new FakeSocket()
let settled = false
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settled = true
settledError = error
},
})
socket.emitData(Buffer.from('\n \n'))
expect(settled).toBe(false)
socket.emitData(Buffer.from(`${JSON.stringify({ type: 'response' })}\n`))
expect(settled).toBe(true)
expect(settledError).toBeUndefined()
expect(socket.ended).toBe(true)
})
test('continues scanning when blank and valid frames share one chunk', () => {
const socket = new FakeSocket()
let settled = false
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settled = true
settledError = error
},
})
socket.emitData(
Buffer.from(`\n${JSON.stringify({ type: 'response' })}\n`),
)
expect(settled).toBe(true)
expect(settledError).toBeUndefined()
expect(socket.ended).toBe(true)
})
test('rejects receiver error frames', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
})
socket.emitData(
Buffer.from(`${JSON.stringify({ type: 'error', data: 'denied' })}\n`),
)
expect(settledError?.message).toBe('denied')
expect(socket.destroyed).toBe(true)
})
test('ignores unrelated receiver frames until a terminal response arrives', () => {
const socket = new FakeSocket()
let settled = false
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settled = true
settledError = error
},
})
socket.emitData(
Buffer.from(
`${JSON.stringify({ type: 'notification', data: 'queued' })}\n`,
),
)
expect(settled).toBe(false)
socket.emitData(Buffer.from(`${JSON.stringify({ type: 'response' })}\n`))
expect(settled).toBe(true)
expect(settledError).toBeUndefined()
})
test('uses custom socket error formatting', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
formatSocketError: error =>
new Error(`wrapped:${(error as Error).message}`),
})
socket.emit('error', new Error('connect failed'))
expect(settledError?.message).toBe('wrapped:connect failed')
expect(socket.destroyed).toBe(true)
})
test('rejects socket end before response', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
})
socket.emit('end')
expect(settledError?.message).toBe('UDS socket ended before response')
expect(socket.destroyed).toBe(true)
})
test('rejects clean socket close before response', () => {
const socket = new FakeSocket()
let settledError: Error | undefined
attachUdsResponseReader(asSocket(socket), {
maxFrameBytes: 128,
onSettled: error => {
settledError = error
},
})
socket.emit('close', false)
expect(settledError?.message).toBe('UDS socket closed before response')
expect(socket.destroyed).toBe(true)
})
})

View File

@@ -87,10 +87,8 @@ export function buildSystemInitMessage(inputs: SystemInitInputs): SDKMessage {
// Hidden from public SDK types — ant-only UDS messaging socket path
if (feature('UDS_INBOX')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const udsMessaging =
require('../udsMessaging.js') as typeof import('../udsMessaging.js')
;(initMessage as Record<string, unknown>).messaging_socket_path =
udsMessaging.getUdsMessagingSocketPath()
require('../udsMessaging.js').getUdsMessagingSocketPath()
/* eslint-enable @typescript-eslint/no-require-imports */
}
initMessage.fast_mode_state = getFastModeState(inputs.model, inputs.fastMode)

View File

@@ -10,11 +10,15 @@ import type { Socket } from 'net'
export type NdjsonFramerOptions = {
maxFrameBytes?: number
onFrameError?: (error: Error) => void
destroyOnFrameError?: boolean
onInvalidFrame?: (error: Error) => void
destroyOnInvalidFrame?: boolean
}
/**
* Attach an NDJSON framer to a socket. Calls `onMessage` for each
* complete JSON line received. Malformed lines are silently skipped.
* complete JSON line received. Malformed lines are skipped by default;
* callers may opt into error callbacks or socket destruction.
*
* @param parse - Optional custom JSON parser (defaults to JSON.parse).
* Useful when the caller uses a wrapped parser like jsonParse
@@ -35,15 +39,26 @@ export function attachNdjsonFramer<T = unknown>(
`NDJSON frame exceeded ${maxFrameBytes} bytes (${bytes})`,
)
options.onFrameError?.(error)
socket.destroy(error)
if (options.destroyOnFrameError ?? true) {
socket.destroy(error)
}
}
const rejectInvalidFrame = (error: unknown): void => {
const frameError =
error instanceof Error ? error : new Error('Invalid NDJSON frame')
options.onInvalidFrame?.(frameError)
if (options.destroyOnInvalidFrame ?? false) {
socket.destroy(frameError)
}
}
const emitLine = (line: string): void => {
if (!line.trim()) return
try {
onMessage(parse(line))
} catch {
// Malformed JSON — skip
} catch (error) {
rejectInvalidFrame(error)
}
}

View File

@@ -1246,13 +1246,8 @@ export async function runInProcessTeammate(
// Track in-progress tool use IDs for animation in transcript view
let inProgressToolUseIDs = task.inProgressToolUseIDs
if (message.type === 'assistant') {
for (const block of Array.isArray(message.message!.content)
? message.message!.content
: []) {
if (
typeof block !== 'string' &&
block.type === 'tool_use'
) {
for (const block of (Array.isArray(message.message!.content) ? message.message!.content : [])) {
if (typeof block !== 'string' && block.type === 'tool_use') {
inProgressToolUseIDs = new Set([
...(inProgressToolUseIDs ?? []),
block.id,
@@ -1323,10 +1318,7 @@ export async function runInProcessTeammate(
setAppState,
)
if (currentAutonomyRunId) {
await markAutonomyRunFailed(
currentAutonomyRunId,
ERROR_MESSAGE_USER_ABORT,
)
await markAutonomyRunFailed(currentAutonomyRunId, ERROR_MESSAGE_USER_ABORT)
currentAutonomyRunId = undefined
}
} else if (currentAutonomyRunId) {

View File

@@ -82,6 +82,8 @@ export const MAX_UDS_INBOX_ENTRIES = 1_000
export const MAX_UDS_FRAME_BYTES = 64 * 1024
export const MAX_UDS_INBOX_BYTES = 2 * 1024 * 1024
export const MAX_UDS_CLIENTS = 128
export const UDS_AUTH_TIMEOUT_MS = 2_000
export const UDS_IDLE_TIMEOUT_MS = 30_000
// ---------------------------------------------------------------------------
// Public API — socket path helpers
@@ -339,6 +341,43 @@ function writeSocketMessage(socket: Socket, message: UdsMessage): void {
socket.write(jsonStringify(message) + '\n')
}
function writeSocketMessageAndDestroy(socket: Socket, message: UdsMessage): void {
if (socket.destroyed) return
socket.write(jsonStringify(message) + '\n', () => {
if (!socket.destroyed) socket.destroy()
})
}
function writeSocketErrorAndDestroy(socket: Socket, data: string): void {
writeSocketMessageAndDestroy(socket, {
type: 'error',
data,
ts: new Date().toISOString(),
})
}
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
const maybeUnref = (timer as { unref?: () => void }).unref
if (typeof maybeUnref === 'function') {
maybeUnref.call(timer)
}
}
async function closeServer(serverToClose: Server): Promise<void> {
await new Promise<void>(resolve => {
serverToClose.close(() => resolve())
})
}
async function removeSocketPath(path: string): Promise<void> {
if (process.platform === 'win32') return
try {
await unlink(path)
} catch {
// Already gone.
}
}
function stripAuthToken(message: UdsMessage): UdsMessage {
const { authToken: _authToken, ...metaWithoutAuth } = message.meta ?? {}
return {
@@ -391,10 +430,9 @@ export async function startUdsMessaging(
}
const token = ensureAuthToken()
let startedServer: Server | null = null
let exportedSocketEnv = false
try {
await writeCapabilityFile(path, token)
socketPath = path
await new Promise<void>((resolve, reject) => {
const srv = createServer(socket => {
if (clients.size >= MAX_UDS_CLIENTS) {
@@ -408,6 +446,24 @@ export async function startUdsMessaging(
logForDebugging(
`[udsMessaging] client connected (total: ${clients.size})`,
)
let authenticated = false
let closing = false
const closeWithError = (data: string): void => {
if (closing || socket.destroyed) return
closing = true
socket.pause()
writeSocketErrorAndDestroy(socket, data)
}
const authTimer = setTimeout(() => {
if (authenticated || socket.destroyed) return
logForDebugging('[udsMessaging] closing unauthenticated idle client')
closeWithError('authentication timeout')
}, UDS_AUTH_TIMEOUT_MS)
unrefTimer(authTimer)
socket.setTimeout(UDS_IDLE_TIMEOUT_MS, () => {
logForDebugging('[udsMessaging] closing idle client')
closeWithError('idle timeout')
})
attachNdjsonFramer<UdsMessage>(
socket,
@@ -416,17 +472,13 @@ export async function startUdsMessaging(
logForDebugging(
`[udsMessaging] rejected unauthenticated message type=${msg.type}`,
)
if (!socket.destroyed) {
socket.write(
jsonStringify({
type: 'error',
data: 'unauthorized',
ts: new Date().toISOString(),
} satisfies UdsMessage) + '\n',
)
}
closeWithError('unauthorized')
return
}
if (!authenticated) {
authenticated = true
clearTimeout(authTimer)
}
// Handle ping with automatic pong
if (msg.type === 'ping') {
@@ -447,11 +499,7 @@ export async function startUdsMessaging(
status: 'pending',
}
if (!enqueueInboxEntry(entry)) {
writeSocketMessage(socket, {
type: 'error',
data: 'inbox full',
ts: new Date().toISOString(),
})
closeWithError('inbox full')
return
}
logForDebugging(
@@ -470,21 +518,40 @@ export async function startUdsMessaging(
maxFrameBytes: MAX_UDS_FRAME_BYTES,
onFrameError: error => {
logForDebugging(`[udsMessaging] ${error.message}`)
closeWithError(error.message)
},
onInvalidFrame: error => {
logForDebugging(
`[udsMessaging] invalid client frame: ${errorMessage(error)}`,
)
closeWithError('invalid frame')
},
destroyOnFrameError: false,
},
)
socket.on('close', () => {
clearTimeout(authTimer)
clients.delete(socket)
})
socket.on('error', err => {
clearTimeout(authTimer)
clients.delete(socket)
logForDebugging(`[udsMessaging] client error: ${errorMessage(err)}`)
})
})
srv.on('error', reject)
const rejectBeforeListen = (error: Error): void => {
reject(error)
}
const logRuntimeError = (error: Error): void => {
logForDebugging(
`[udsMessaging] server error on ${path}${opts?.isExplicit ? ' (explicit)' : ''}: ${errorMessage(error)}`,
)
}
srv.once('error', rejectBeforeListen)
srv.listen(path, () => {
void (async () => {
@@ -492,19 +559,41 @@ export async function startUdsMessaging(
if (process.platform !== 'win32') {
await chmod(path, 0o600)
}
srv.off('error', rejectBeforeListen)
srv.on('error', logRuntimeError)
server = srv
// Export so child processes can discover the socket
process.env.CLAUDE_CODE_MESSAGING_SOCKET = path
logForDebugging(
`[udsMessaging] server listening on ${path}${opts?.isExplicit ? ' (explicit)' : ''}`,
)
startedServer = srv
resolve()
} catch (error) {
srv.close(() => reject(error))
srv.off('error', rejectBeforeListen)
const closeError =
error instanceof Error ? error : new Error(errorMessage(error))
let rejected = false
const rejectOnce = (): void => {
if (rejected) return
rejected = true
reject(closeError)
}
const fallback = setTimeout(rejectOnce, 1_000)
unrefTimer(fallback)
srv.close(() => {
clearTimeout(fallback)
rejectOnce()
})
}
})()
})
})
await writeCapabilityFile(path, token)
socketPath = path
// Export so child processes can discover the socket only after the
// capability file exists and the listener is ready.
process.env.CLAUDE_CODE_MESSAGING_SOCKET = path
exportedSocketEnv = true
logForDebugging(
`[udsMessaging] server listening on ${path}${opts?.isExplicit ? ' (explicit)' : ''}`,
)
} catch (error) {
if (capabilityFilePath) {
try {
@@ -514,7 +603,18 @@ export async function startUdsMessaging(
}
capabilityFilePath = null
}
if (startedServer) {
await closeServer(startedServer)
}
if (server === startedServer) {
server = null
}
await removeSocketPath(path)
if (exportedSocketEnv) {
delete process.env.CLAUDE_CODE_MESSAGING_SOCKET
}
socketPath = null
defaultSocketPath = null
authToken = null
throw error
}
@@ -529,6 +629,7 @@ export async function startUdsMessaging(
* Stop the UDS messaging server and clean up the socket file.
*/
export async function stopUdsMessaging(): Promise<void> {
defaultSocketPath = null
if (!server) return
// Close all connected clients
@@ -547,13 +648,7 @@ export async function stopUdsMessaging(): Promise<void> {
// Remove socket file (skip on Windows — pipe paths aren't files)
if (socketPath) {
if (process.platform !== 'win32') {
try {
await unlink(socketPath)
} catch {
// Already gone
}
}
await removeSocketPath(socketPath)
delete process.env.CLAUDE_CODE_MESSAGING_SOCKET
logForDebugging(
`[udsMessaging] server stopped, socket removed: ${socketPath}`,

View File

@@ -1,4 +1,5 @@
import type { Socket } from 'net'
import { StringDecoder } from 'node:string_decoder'
import { errorMessage } from './errors.js'
import { jsonParse } from './slowOperations.js'
import type { UdsMessage } from './udsMessaging.js'
@@ -16,11 +17,11 @@ export function getChunkBytes(chunk: string | Buffer): number {
: chunk.byteLength
}
function parseResponseLine(line: string): UdsMessage | null {
function parseResponseLine(line: string): UdsMessage {
try {
return jsonParse(line) as UdsMessage
} catch {
return null
throw new Error('Invalid UDS response frame')
}
}
@@ -29,35 +30,58 @@ export function attachUdsResponseReader(
options: UdsResponseReaderOptions,
): void {
let buffer = ''
let bufferBytes = 0
let settled = false
const decoder = new StringDecoder('utf8')
const finish = (error?: Error): void => {
function cleanupListeners(): void {
socket.off('data', onData)
socket.off('error', onError)
socket.off('end', onEnd)
socket.off('close', onClose)
}
function finish(error?: Error): void {
if (settled) return
settled = true
buffer = ''
bufferBytes = 0
cleanupListeners()
if (error) {
socket.destroy(error)
socket.destroy()
} else {
socket.end()
}
options.onSettled(error)
}
socket.on('data', chunk => {
if (
Buffer.byteLength(buffer, 'utf8') + getChunkBytes(chunk) >
options.maxFrameBytes
) {
function onData(chunk: Buffer): void {
const decoded = decoder.write(chunk)
const decodedBytes = Buffer.byteLength(decoded, 'utf8')
if (bufferBytes + decodedBytes > options.maxFrameBytes) {
finish(new Error('UDS response frame exceeded size limit'))
return
}
buffer += chunk.toString()
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
const response = parseResponseLine(line)
if (!response) continue
buffer += decoded
bufferBytes += decodedBytes
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex)
const consumed = buffer.slice(0, newlineIndex + 1)
buffer = buffer.slice(newlineIndex + 1)
bufferBytes -= Buffer.byteLength(consumed, 'utf8')
if (!line.trim()) {
newlineIndex = buffer.indexOf('\n')
continue
}
let response: UdsMessage
try {
response = parseResponseLine(line)
} catch (error) {
finish(error instanceof Error ? error : new Error(errorMessage(error)))
return
}
if (
response.type === 'response' ||
(options.acceptPong === true && response.type === 'pong')
@@ -69,13 +93,28 @@ export function attachUdsResponseReader(
finish(new Error(response.data ?? 'UDS receiver rejected message'))
return
}
newlineIndex = buffer.indexOf('\n')
}
})
}
socket.on('error', error => {
function onError(error: Error): void {
finish(
options.formatSocketError?.(error) ??
(error instanceof Error ? error : new Error(errorMessage(error))),
)
})
}
function onEnd(): void {
finish(new Error('UDS socket ended before response'))
}
function onClose(hadError: boolean): void {
if (hadError) return
finish(new Error('UDS socket closed before response'))
}
socket.on('data', onData)
socket.on('error', onError)
socket.on('end', onEnd)
socket.on('close', onClose)
}