Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 3 additions & 33 deletions src/main/lib/binaryReadGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,6 @@ const TEXT_LIKE_MIMES = new Set([
'application/x-sh'
])

const DOCUMENT_MIMES = new Set([
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.oasis.opendocument.spreadsheet'
])

const ALWAYS_BINARY_MIMES = new Set([
'application/zip',
'application/x-zip',
Expand All @@ -36,10 +25,6 @@ export function isTextLikeMime(mimeType: string): boolean {
return mimeType.startsWith('text/') || TEXT_LIKE_MIMES.has(mimeType)
}

export function isDocumentMime(mimeType: string): boolean {
return DOCUMENT_MIMES.has(mimeType)
}

export async function shouldRejectAcpTextRead(filePath: string): Promise<{
reject: boolean
mimeType: string
Expand All @@ -58,31 +43,16 @@ export async function shouldRejectAcpTextRead(filePath: string): Promise<{
return { reject: true, mimeType }
}

export async function shouldRejectAgentBinaryRead(
filePath: string,
mimeType: string
): Promise<boolean> {
export function shouldRejectAgentBinaryRead(mimeType: string): boolean {
if (mimeType.startsWith('image/')) {
return false
}

if (isTextLikeMime(mimeType) || isDocumentMime(mimeType) || mimeType === 'text/csv') {
return false
}

if (
return (
ALWAYS_BINARY_MIMES.has(mimeType) ||
mimeType.startsWith('audio/') ||
mimeType.startsWith('video/')
) {
return true
}

if (mimeType === 'application/octet-stream') {
return !(await isLikelyTextFile(filePath))
}

return false
)
}

export function buildBinaryReadGuidance(
Expand Down
10 changes: 9 additions & 1 deletion src/main/tool/agentTools/agentFileSystemHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,15 @@ export class AgentFileSystemHandler {
enforceAllowed: false,
accessType: 'read'
})
const fullContent = await fs.readFile(validPath, 'utf-8')
const bytes = await fs.readFile(validPath)
let fullContent: string
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
fullContent = bytes.subarray(2).toString('utf16le')
} else if (bytes[0] === 0xfe && bytes[1] === 0xff) {
fullContent = new TextDecoder('utf-16be').decode(bytes.subarray(2))
} else {
fullContent = bytes.toString('utf8').replace(/^\uFEFF/, '')
}
const totalLength = fullContent.length

// Determine effective limit
Expand Down
4 changes: 2 additions & 2 deletions src/main/tool/agentTools/agentToolManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,7 @@ export class AgentToolManager {
)
const mimeType = await this.getFileService().getMimeType(validPath)

if (await shouldRejectAgentBinaryRead(validPath, mimeType)) {
if (shouldRejectAgentBinaryRead(mimeType)) {
return {
content: buildBinaryReadGuidance(validPath, mimeType, 'agent')
}
Expand Down Expand Up @@ -1678,7 +1678,7 @@ export class AgentToolManager {
if (mimeType === 'text/csv') {
return false
}
if (mimeType.startsWith('text/')) {
if (mimeType.startsWith('text/') || mimeType === 'application/octet-stream') {
return true
}

Expand Down
33 changes: 11 additions & 22 deletions test/main/lib/binaryReadGuard.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { describe, expect, it } from 'vitest'
import { shouldRejectAgentBinaryRead } from '../../../src/main/lib/binaryReadGuard'
import { isLikelyTextFile } from '@/file/mime'

vi.mock('@/file/mime', () => ({
detectMimeType: vi.fn(),
isLikelyTextFile: vi.fn()
}))

describe('binaryReadGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('falls back to text detection for application/octet-stream', async () => {
vi.mocked(isLikelyTextFile).mockResolvedValue(true)

await expect(
shouldRejectAgentBinaryRead('/tmp/maybe-text.bin', 'application/octet-stream')
).resolves.toBe(false)
it('allows application/octet-stream without binary sniffing', () => {
expect(shouldRejectAgentBinaryRead('application/octet-stream')).toBe(false)
})

it('still rejects octet-stream files that do not look like text', async () => {
vi.mocked(isLikelyTextFile).mockResolvedValue(false)
it.each(['application/zip', 'application/wasm', 'audio/mpeg', 'video/mp4'])(
'still rejects known binary MIME %s',
(mimeType) => {
expect(shouldRejectAgentBinaryRead(mimeType)).toBe(true)
}
)

await expect(
shouldRejectAgentBinaryRead('/tmp/blob.bin', 'application/octet-stream')
).resolves.toBe(true)
it('keeps images available for vision reads', () => {
expect(shouldRejectAgentBinaryRead('image/png')).toBe(false)
})
})
35 changes: 35 additions & 0 deletions test/main/tool/agentTools/agentToolManagerRead.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,41 @@ describe('AgentToolManager read routing', () => {
expect(fileService.prepareFileCompletely).not.toHaveBeenCalled()
})

it.each([
[
'UTF-16LE',
'.tmp-change-le.diff',
Buffer.from(`\uFEFFdiff --git a/file.ts b/file.ts\n+const value = 1\n`, 'utf16le')
],
[
'UTF-16BE',
'.tmp-change-be.diff',
Buffer.from(`\uFEFFdiff --git a/file.ts b/file.ts\n+const value = 1\n`, 'utf16le').swap16()
],
[
'UTF-8 BOM',
'.tmp-change-utf8.diff',
Buffer.concat([
Buffer.from([0xef, 0xbb, 0xbf]),
Buffer.from(`diff --git a/file.ts b/file.ts\n+const value = 1\n`, 'utf8')
])
]
])('reads %s code files reported as application/octet-stream', async (_encoding, name, bytes) => {
const filePath = path.join(workspaceDir, name)
await fs.writeFile(filePath, bytes)
fileService.getMimeType.mockResolvedValue('application/octet-stream')

const result = (await manager.callTool('read', { path: name }, 'conv1')) as {
content: string
}

expect(result.content).toContain('diff --git a/file.ts b/file.ts')
expect(result.content).toContain('+const value = 1')
expect(result.content).not.toContain('\uFEFF')
expect(result.content).not.toContain('\u0000')
expect(fileService.prepareFileCompletely).not.toHaveBeenCalled()
})

it('uses the Agent auto-truncate limit while preserving an explicit read limit', async () => {
const filePath = path.join(workspaceDir, 'large-note.txt')
await fs.writeFile(filePath, 'x'.repeat(1_500), 'utf-8')
Expand Down