mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
* fix: keep UDS peer failures structured CodeRabbit and Claude cross-review identified that timeout and raw peer connection failures should share one observable error contract. UDS peer failures now use UdsPeerConnectionError consistently, and connectToPeer hands the socket lifecycle back to the caller after a successful connection instead of retaining an internal timeout or error listener. The tests cover the real socket paths with capability files, timeout behavior, connection failure structure, post-connect listener handoff, AgentSummary rescheduling observations, and platform-specific mailbox directory errno handling. Constraint: Preserve the 5000ms production timeout default while allowing tests to exercise timeout paths quickly. Rejected: Suppress CodeRabbit warnings in tests | would hide the real timeout/error contract gap. Rejected: Keep connectToPeer post-connect error listener | it would silently swallow caller-owned socket errors. Confidence: high Scope-risk: narrow Directive: Keep UDS send/connect timeout and socket-error paths on the same structured peer error contract. Tested: bun test src/utils/__tests__/udsMessaging.test.ts src/services/AgentSummary/__tests__/agentSummary.test.ts src/utils/__tests__/teammateMailbox.test.ts Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: bun run test:all Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage Tested: bun run build Tested: bun run build:vite Tested: omx ask claude simplify review artifact .omx/artifacts/claude-review-only-cross-check-for-pr-374-on-branch-codex-codecov-r-2026-04-27T08-17-47-309Z.md Tested: omx ask claude security review artifact .omx/artifacts/claude-security-review-cross-check-for-pr-374-current-working-tree--2026-04-27T08-26-54-079Z.md Not-tested: GitHub-hosted CodeRabbit refresh until pushed. * docs: clarify UDS peer socket ownership CodeRabbit's #375 pass found that connectToPeer now correctly hands socket errors to the caller, but the JSDoc needed to spell out that contract. The lifecycle test also uses a less brittle post-connect timeout so slow CI does not turn the ownership check into a connection-speed race. Constraint: The raw socket API intentionally detaches its internal listener after successful connect so caller-owned errors are not swallowed. Rejected: Keep the test timeout at 50ms | it tests scheduler speed instead of socket lifecycle ownership. Confidence: high Scope-risk: narrow Directive: connectToPeer callers must attach their own error listener immediately after awaiting the socket. Tested: bun test src/utils/__tests__/udsMessaging.test.ts Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: git diff --check Tested: bun run test:all Not-tested: GitHub-hosted CodeRabbit refresh until pushed. * fix: close peer socket listener handoff window CodeRabbit and Claude review found that documenting caller-owned raw socket errors still left a Promise handoff window and a stale timeout-listener risk. The peer connection API now requires a caller error handler and installs it before resolving, while cleanup removes internal error and timeout listeners on every path. Constraint: Keep the fix precise to PR #375 review feedback and avoid warning suppression or fallback behavior. Rejected: Leave the behavior documented only | still permits an unhandled socket error window between resolve and caller listener attachment. Rejected: Keep a no-op internal error listener | would silently swallow caller-owned socket errors. Confidence: high Scope-risk: narrow Directive: Do not add raw connectToPeer callers without providing a real onSocketError handler and capability handshake. Tested: bun test src/utils/__tests__/udsMessaging.test.ts src/services/AgentSummary/__tests__/agentSummary.test.ts Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: bun run test:all Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage Tested: bun run build Tested: bun run build:vite Tested: bun audit Not-tested: Manual external ACP peer runtime beyond repository tests. * fix: use a deadline timer for peer connects The raw socket handoff no longer needs Socket#setTimeout; an ordinary connection deadline keeps the timeout behavior while avoiding an internal socket timeout listener that has no reliable UDS integration path to exercise. Constraint: Keep Codecov coverage honest without adding ignore pragmas, mocks, or fallback suppression. Rejected: c8 ignore on the timeout listener | hides the uncovered branch instead of simplifying the lifecycle. Rejected: keep Socket#setTimeout listener | leaves a socket listener lifecycle to manage for a connect-only deadline. Confidence: high Scope-risk: narrow Directive: Keep connectToPeer errors caller-owned via onSocketError and reject pre-connect failures with UdsPeerConnectionError. Tested: bun test src/utils/__tests__/udsMessaging.test.ts src/services/AgentSummary/__tests__/agentSummary.test.ts Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: bun test src/utils/__tests__/udsMessaging.test.ts --coverage --coverage-reporter lcov --coverage-dir coverage-uds Tested: bun run test:all Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage Tested: bun run build Tested: bun run build:vite Tested: bun audit Not-tested: Manual external ACP peer runtime beyond repository tests. --------- Co-authored-by: unraid <local@unraid.local>
768 lines
24 KiB
TypeScript
768 lines
24 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
import {
|
|
chmod,
|
|
mkdir,
|
|
mkdtemp,
|
|
readdir,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
unlink,
|
|
writeFile,
|
|
} from 'node:fs/promises'
|
|
import { createHash } from 'node:crypto'
|
|
import { createConnection, createServer, type Socket } 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
|
|
let tempConfigDir = ''
|
|
|
|
function socketPath(label: string): string {
|
|
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}-${label}`
|
|
if (process.platform === 'win32') {
|
|
return `\\\\.\\pipe\\claude-code-test-${suffix}`
|
|
}
|
|
return join(tmpdir(), 'claude-code-test', `${suffix}.sock`)
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
async function waitForEnqueues(
|
|
expected: number,
|
|
sendMessages: () => Promise<void>,
|
|
): Promise<void> {
|
|
let count = 0
|
|
let resolveDone: (() => void) | undefined
|
|
const done = new Promise<void>(resolve => {
|
|
resolveDone = resolve
|
|
})
|
|
|
|
setOnEnqueue(() => {
|
|
count++
|
|
if (count >= expected) resolveDone?.()
|
|
})
|
|
|
|
await sendMessages()
|
|
await Promise.race([
|
|
done,
|
|
sleep(5_000).then(() => {
|
|
throw new Error(`Timed out waiting for ${expected} UDS enqueues`)
|
|
}),
|
|
])
|
|
setOnEnqueue(null)
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
tempConfigDir = await mkdtemp(join(tmpdir(), 'uds-messaging-home-'))
|
|
process.env.CLAUDE_CONFIG_DIR = tempConfigDir
|
|
})
|
|
|
|
afterEach(async () => {
|
|
setOnEnqueue(null)
|
|
drainInbox()
|
|
await stopUdsMessaging()
|
|
if (previousConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
|
}
|
|
if (tempConfigDir) {
|
|
await rm(tempConfigDir, { recursive: true, force: true })
|
|
tempConfigDir = ''
|
|
}
|
|
})
|
|
|
|
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
|
|
await new Promise<void>(resolve => {
|
|
server.close(() => resolve())
|
|
})
|
|
}
|
|
|
|
describe('UDS inbox retention', () => {
|
|
test('drainInbox returns each pending socket message once', async () => {
|
|
const path = socketPath('drain')
|
|
await startUdsMessaging(path, { isExplicit: true })
|
|
expect(process.env.CLAUDE_CODE_MESSAGING_TOKEN).toBeUndefined()
|
|
|
|
await waitForEnqueues(2, async () => {
|
|
await sendUdsMessage(path, { type: 'text', data: 'one' })
|
|
await sendUdsMessage(path, { type: 'text', data: 'two' })
|
|
})
|
|
|
|
const drained = drainInbox()
|
|
expect(drained.map(entry => entry.message.data)).toEqual(['one', 'two'])
|
|
expect(drained.every(entry => entry.status === 'processed')).toBe(true)
|
|
expect(drainInbox()).toEqual([])
|
|
})
|
|
|
|
test('inbox is capped when messages arrive faster than they are drained', async () => {
|
|
const path = socketPath('cap')
|
|
await startUdsMessaging(path, { isExplicit: true })
|
|
|
|
await waitForEnqueues(MAX_UDS_INBOX_ENTRIES, async () => {
|
|
for (let i = 0; i < MAX_UDS_INBOX_ENTRIES; i++) {
|
|
await sendUdsMessage(path, { type: 'text', data: String(i) })
|
|
}
|
|
})
|
|
await expect(
|
|
sendUdsMessage(path, { type: 'text', data: 'overflow' }),
|
|
).rejects.toThrow('inbox full')
|
|
|
|
const drained = drainInbox()
|
|
expect(drained).toHaveLength(MAX_UDS_INBOX_ENTRIES)
|
|
expect(drained[0]?.message.data).toBe('0')
|
|
expect(drained.at(-1)?.message.data).toBe(String(MAX_UDS_INBOX_ENTRIES - 1))
|
|
})
|
|
|
|
test('inbox is capped by retained bytes before entry count', async () => {
|
|
const path = socketPath('byte-cap')
|
|
await startUdsMessaging(path, { isExplicit: true })
|
|
|
|
const payload = 'x'.repeat(32 * 1024)
|
|
let accepted = 0
|
|
for (;;) {
|
|
try {
|
|
await sendUdsMessage(path, { type: 'text', data: payload })
|
|
accepted++
|
|
if (accepted > MAX_UDS_INBOX_BYTES / payload.length + 20) {
|
|
throw new Error('byte cap was not enforced')
|
|
}
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error)
|
|
expect((error as Error).message).toContain('inbox full')
|
|
break
|
|
}
|
|
}
|
|
|
|
const drained = drainInbox()
|
|
expect(drained.length).toBe(accepted)
|
|
expect(drained.length).toBeLessThan(MAX_UDS_INBOX_ENTRIES)
|
|
})
|
|
|
|
test('ping replies with pong without enqueueing inbox work', async () => {
|
|
const path = socketPath('ping')
|
|
await startUdsMessaging(path, { isExplicit: true })
|
|
|
|
await sendUdsMessage(path, { type: 'ping' })
|
|
expect(drainInbox()).toEqual([])
|
|
})
|
|
|
|
test('udsClient helpers authenticate through the capability file', async () => {
|
|
const path = socketPath('uds-client')
|
|
await startUdsMessaging(path, { isExplicit: true })
|
|
const { isPeerAlive, sendToUdsSocket } = await import('../udsClient.js')
|
|
|
|
expect(await isPeerAlive(path)).toBe(true)
|
|
await waitForEnqueues(1, async () => {
|
|
await sendToUdsSocket(path, 'hello from client')
|
|
})
|
|
|
|
const drained = drainInbox()
|
|
expect(drained).toHaveLength(1)
|
|
expect(drained[0]?.message.data).toBe('hello from client')
|
|
expect(drained[0]?.message.meta).toBeUndefined()
|
|
})
|
|
|
|
test('udsClient peer probe fails closed on oversized pong frames', async () => {
|
|
const path = socketPath('uds-client-oversized-pong')
|
|
if (process.platform !== 'win32') {
|
|
await mkdir(dirname(path), { recursive: true })
|
|
}
|
|
const receiver = createServer(socket => {
|
|
socket.on('data', () => {
|
|
socket.write('x'.repeat(MAX_UDS_FRAME_BYTES + 1))
|
|
})
|
|
})
|
|
await new Promise<void>((resolve, reject) => {
|
|
receiver.on('error', reject)
|
|
receiver.listen(path, () => resolve())
|
|
})
|
|
|
|
try {
|
|
const { isPeerAlive } = await import('../udsClient.js')
|
|
expect(await isPeerAlive(path, 3_000, 'test-token')).toBe(false)
|
|
} finally {
|
|
await closeServer(receiver)
|
|
if (process.platform !== 'win32') {
|
|
await unlink(path).catch(() => undefined)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('udsClient send fails closed when no capability token exists', async () => {
|
|
const path = socketPath('uds-client-no-token')
|
|
const { sendToUdsSocket } = await import('../udsClient.js')
|
|
|
|
await expect(sendToUdsSocket(path, 'hello')).rejects.toThrow(
|
|
'No auth token found',
|
|
)
|
|
})
|
|
|
|
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, UdsPeerConnectionError } = await import(
|
|
'../udsClient.js'
|
|
)
|
|
|
|
const error = await sendToUdsSocket(path, 'hello').then(
|
|
() => undefined,
|
|
err => err,
|
|
)
|
|
expect(error).toBeInstanceOf(UdsPeerConnectionError)
|
|
if (!(error instanceof UdsPeerConnectionError)) {
|
|
throw new Error('Expected UDS peer connection error')
|
|
}
|
|
expect(error.socketPath).toBe(path)
|
|
expect(error.message).not.toContain('test-token')
|
|
})
|
|
|
|
test('udsClient send reports response timeouts as peer connection errors', async () => {
|
|
const path = socketPath('uds-client-timeout')
|
|
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',
|
|
)
|
|
if (process.platform !== 'win32') {
|
|
await mkdir(dirname(path), { recursive: true })
|
|
}
|
|
|
|
const sockets = new Set<Socket>()
|
|
const receiver = createServer(socket => {
|
|
sockets.add(socket)
|
|
socket.on('close', () => {
|
|
sockets.delete(socket)
|
|
})
|
|
socket.on('data', () => undefined)
|
|
})
|
|
await new Promise<void>((resolve, reject) => {
|
|
receiver.on('error', reject)
|
|
receiver.listen(path, () => resolve())
|
|
})
|
|
|
|
try {
|
|
const { sendToUdsSocket, UdsPeerConnectionError } = await import(
|
|
'../udsClient.js'
|
|
)
|
|
|
|
const error = await sendToUdsSocket(path, 'hello', 200).then(
|
|
() => undefined,
|
|
err => err,
|
|
)
|
|
expect(error).toBeInstanceOf(UdsPeerConnectionError)
|
|
if (!(error instanceof UdsPeerConnectionError)) {
|
|
throw new Error('Expected UDS peer connection timeout error')
|
|
}
|
|
expect(error.socketPath).toBe(path)
|
|
expect(error.cause).toBeInstanceOf(Error)
|
|
if (!(error.cause instanceof Error)) {
|
|
throw new Error('Expected timeout cause')
|
|
}
|
|
expect(error.cause.message).toBe('Connection timed out')
|
|
expect(error.message).not.toContain('test-token')
|
|
} finally {
|
|
for (const socket of sockets) {
|
|
socket.destroy()
|
|
}
|
|
await closeServer(receiver)
|
|
if (process.platform !== 'win32') {
|
|
await unlink(path).catch(() => undefined)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('connectToPeer reports connection failures as peer connection errors', async () => {
|
|
const path = socketPath('uds-connect-error')
|
|
const { connectToPeer, UdsPeerConnectionError } = await import(
|
|
'../udsClient.js'
|
|
)
|
|
|
|
const error = await connectToPeer(path, () => {
|
|
throw new Error('Unexpected post-connect socket error')
|
|
}).then(
|
|
() => undefined,
|
|
err => err,
|
|
)
|
|
|
|
expect(error).toBeInstanceOf(UdsPeerConnectionError)
|
|
if (!(error instanceof UdsPeerConnectionError)) {
|
|
throw new Error('Expected UDS peer connection error')
|
|
}
|
|
expect(error.socketPath).toBe(path)
|
|
})
|
|
|
|
test('connectToPeer leaves connected socket lifecycle to the caller', async () => {
|
|
const path = socketPath('uds-connect-lifecycle')
|
|
if (process.platform !== 'win32') {
|
|
await mkdir(dirname(path), { recursive: true })
|
|
}
|
|
|
|
const sockets = new Set<Socket>()
|
|
const receiver = createServer(socket => {
|
|
sockets.add(socket)
|
|
socket.on('close', () => {
|
|
sockets.delete(socket)
|
|
})
|
|
})
|
|
await new Promise<void>((resolve, reject) => {
|
|
receiver.on('error', reject)
|
|
receiver.listen(path, () => resolve())
|
|
})
|
|
|
|
let client: Socket | undefined
|
|
const socketErrors: Error[] = []
|
|
try {
|
|
const { connectToPeer } = await import('../udsClient.js')
|
|
client = await connectToPeer(
|
|
path,
|
|
error => {
|
|
socketErrors.push(error)
|
|
},
|
|
1000,
|
|
)
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
|
|
expect(client.destroyed).toBe(false)
|
|
expect(client.listenerCount('error')).toBe(1)
|
|
|
|
const socketError = new Error('post-connect failure')
|
|
client.emit('error', socketError)
|
|
expect(socketErrors).toEqual([socketError])
|
|
} finally {
|
|
client?.destroy()
|
|
for (const socket of sockets) {
|
|
socket.destroy()
|
|
}
|
|
await closeServer(receiver)
|
|
if (process.platform !== 'win32') {
|
|
await unlink(path).catch(() => undefined)
|
|
}
|
|
}
|
|
})
|
|
|
|
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 })
|
|
|
|
await waitForEnqueues(1, async () => {
|
|
await sendUdsMessage(path, {
|
|
type: 'notification',
|
|
meta: { keep: 'visible' },
|
|
})
|
|
})
|
|
|
|
const drained = drainInbox()
|
|
expect(drained).toHaveLength(1)
|
|
expect(drained[0]?.message.meta).toEqual({ keep: 'visible' })
|
|
expect(drained[0]?.message.meta).not.toHaveProperty('authToken')
|
|
})
|
|
|
|
test('rejects unauthenticated socket messages', async () => {
|
|
const path = socketPath('auth')
|
|
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`)
|
|
})
|
|
conn.setTimeout(5_000, () => {
|
|
conn.destroy()
|
|
reject(new Error('Timed out waiting for auth rejection'))
|
|
})
|
|
conn.on('data', chunk => {
|
|
const text = chunk.toString('utf-8')
|
|
if (text.includes('\n')) {
|
|
responseText = text
|
|
}
|
|
})
|
|
conn.on('close', () => resolve(responseText))
|
|
conn.on('error', reject)
|
|
})
|
|
|
|
expect(JSON.parse(response).type).toBe('error')
|
|
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 })
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const conn = createConnection(path, () => {
|
|
conn.write('x'.repeat(MAX_UDS_FRAME_BYTES + 1))
|
|
})
|
|
conn.setTimeout(5_000, () => {
|
|
conn.destroy()
|
|
reject(new Error('Timed out waiting for oversized frame close'))
|
|
})
|
|
conn.on('close', () => resolve())
|
|
conn.on('error', () => resolve())
|
|
})
|
|
|
|
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') {
|
|
await mkdir(dirname(path), { recursive: true })
|
|
}
|
|
const receiver = createServer(socket => {
|
|
socket.on('data', () => {
|
|
socket.write('x'.repeat(MAX_UDS_FRAME_BYTES + 1))
|
|
})
|
|
})
|
|
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('UDS response frame exceeded size limit')
|
|
} finally {
|
|
await closeServer(receiver)
|
|
if (process.platform !== 'win32') {
|
|
await unlink(path).catch(() => undefined)
|
|
}
|
|
}
|
|
})
|
|
|
|
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 {
|
|
parseUdsTarget(targetWithToken)
|
|
} catch (error) {
|
|
expect((error as Error).message).not.toContain('secret')
|
|
}
|
|
|
|
const { sendToUdsSocket } = await import('../udsClient.js')
|
|
await expect(sendToUdsSocket(targetWithToken, 'hello')).rejects.toThrow(
|
|
'inline auth token',
|
|
)
|
|
})
|
|
|
|
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')
|
|
await startUdsMessaging(path, { isExplicit: true })
|
|
|
|
const mode = (await stat(path)).mode & 0o777
|
|
expect(mode).toBe(0o600)
|
|
})
|
|
|
|
test('fails closed when the capability directory is not private', async () => {
|
|
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
const tempHome = join(
|
|
tmpdir(),
|
|
`uds-capability-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
)
|
|
process.env.CLAUDE_CONFIG_DIR = tempHome
|
|
const capabilityDir = join(tempHome, 'messaging-capabilities')
|
|
await mkdir(capabilityDir, { recursive: true, mode: 0o755 })
|
|
await chmod(capabilityDir, 0o755)
|
|
|
|
try {
|
|
const path = socketPath('broad-capdir')
|
|
await expect(
|
|
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
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
|
}
|
|
await rm(tempHome, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('fails closed when the capability directory is a symlink', async () => {
|
|
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
const tempHome = join(
|
|
tmpdir(),
|
|
`uds-capability-link-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
)
|
|
const target = join(tempHome, 'target')
|
|
process.env.CLAUDE_CONFIG_DIR = tempHome
|
|
await mkdir(target, { recursive: true, mode: 0o700 })
|
|
await symlink(target, join(tempHome, 'messaging-capabilities'), 'dir')
|
|
|
|
try {
|
|
await expect(
|
|
startUdsMessaging(socketPath('symlink-capdir'), { isExplicit: true }),
|
|
).rejects.toThrow('not a private directory')
|
|
} finally {
|
|
if (previousConfigDir === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
|
}
|
|
await rm(tempHome, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('fails closed when an explicit socket parent is not private', async () => {
|
|
const parent = join(
|
|
tmpdir(),
|
|
`uds-socket-parent-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
)
|
|
await mkdir(parent, { recursive: true, mode: 0o755 })
|
|
await chmod(parent, 0o755)
|
|
|
|
try {
|
|
await expect(
|
|
startUdsMessaging(join(parent, 'messaging.sock'), {
|
|
isExplicit: true,
|
|
}),
|
|
).rejects.toThrow('socket parent permissions are too broad')
|
|
} finally {
|
|
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()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|