mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
275 lines
13 KiB
TypeScript
275 lines
13 KiB
TypeScript
/** The `read` endpoint: its four gates and the line window it cuts. */
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { FsError } from '@deepseek-ai/dsh-fs'
|
|
import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
|
|
|
|
let harness: Harness
|
|
let workspace: string
|
|
let outside: string
|
|
|
|
beforeEach(async () => {
|
|
harness = await openWorkspace('dsh-workspace-files-read-')
|
|
workspace = harness.workspace
|
|
outside = harness.outside
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await harness.dispose()
|
|
})
|
|
|
|
const endpoint = (caps?: { maxBytes?: number; maxLines?: number }): ReturnType<Harness['endpoint']> =>
|
|
harness.endpoint(caps)
|
|
|
|
/** Twenty lines, `line 1` through `line 20`, terminated by a final newline. */
|
|
async function twentyLines(): Promise<void> {
|
|
await writeFile(join(workspace, 'long.txt'), `${Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n')}\n`, 'utf8')
|
|
}
|
|
|
|
/** A file whose second line carries a NUL byte past the backend's 8 KiB binary sample. */
|
|
async function lateNul(): Promise<void> {
|
|
await writeFile(join(workspace, 'late-nul.txt'), Buffer.concat([
|
|
Buffer.from(`${'a'.repeat(9000)}\nb`, 'utf8'),
|
|
Buffer.from([0]),
|
|
Buffer.from('c\n', 'utf8'),
|
|
]))
|
|
}
|
|
|
|
describe('workspaceFiles.read — the happy path', () => {
|
|
it('returns the whole file as one page with its absolute path, version, and byte size', async () => {
|
|
await writeFile(join(workspace, 'notes.txt'), 'hello\nworld\n', 'utf8')
|
|
const result = await endpoint().read(agent, 'notes.txt', {}, signal())
|
|
expect(result.text).toBe('hello\nworld')
|
|
expect(result.offset).toBe(1)
|
|
expect(result.lines).toBe(2)
|
|
expect(result.eof).toBe(true)
|
|
expect(result.bytes).toBe(12)
|
|
expect(result.version.length).toBeGreaterThan(0)
|
|
expect(result.absolutePath.endsWith('notes.txt')).toBe(true)
|
|
})
|
|
|
|
it('reads a nested path relative to the workspace root, not to any backend cwd', async () => {
|
|
await mkdir(join(workspace, 'src', 'deep'), { recursive: true })
|
|
await writeFile(join(workspace, 'src', 'deep', 'a.ts'), 'export {}\n', 'utf8')
|
|
const result = await endpoint().read(agent, 'src/deep/a.ts', {}, signal())
|
|
expect(result.text).toBe('export {}')
|
|
})
|
|
|
|
it('returns an empty page for an empty file', async () => {
|
|
await writeFile(join(workspace, 'empty.txt'), '', 'utf8')
|
|
const result = await endpoint().read(agent, 'empty.txt', {}, signal())
|
|
expect(result).toMatchObject({ text: '', lines: 0, eof: true, bytes: 0 })
|
|
})
|
|
|
|
it('accepts multi-byte UTF-8 and counts the file bytes, not its characters', async () => {
|
|
await writeFile(join(workspace, 'zh.txt'), '侧栏', 'utf8')
|
|
const result = await endpoint().read(agent, 'zh.txt', {}, signal())
|
|
expect(result.text).toBe('侧栏')
|
|
expect(result.bytes).toBe(6)
|
|
})
|
|
})
|
|
|
|
describe('workspaceFiles.read — the line window', () => {
|
|
it('cuts the requested lines and reports that more follow', async () => {
|
|
await twentyLines()
|
|
const result = await endpoint().read(agent, 'long.txt', { offset: 6, limit: 3 }, signal())
|
|
expect(result).toMatchObject({ offset: 6, text: 'line 6\nline 7\nline 8', lines: 3, eof: false })
|
|
})
|
|
|
|
it('reports eof on the page that holds the last line, whether or not the limit is reached', async () => {
|
|
await twentyLines()
|
|
const service = endpoint()
|
|
const exact = await service.read(agent, 'long.txt', { offset: 16, limit: 5 }, signal())
|
|
expect(exact).toMatchObject({ text: 'line 16\nline 17\nline 18\nline 19\nline 20', lines: 5, eof: true })
|
|
const beyond = await service.read(agent, 'long.txt', { offset: 19, limit: 10 }, signal())
|
|
expect(beyond).toMatchObject({ text: 'line 19\nline 20', lines: 2, eof: true })
|
|
})
|
|
|
|
it('treats a final newline as the last line terminator, not as an empty line after it', async () => {
|
|
await writeFile(join(workspace, 'two.txt'), 'a\nb\n', 'utf8')
|
|
await writeFile(join(workspace, 'three.txt'), 'a\nb\n\n', 'utf8')
|
|
const service = endpoint()
|
|
expect(await service.read(agent, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true })
|
|
expect(await service.read(agent, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false })
|
|
// The third line is empty, not absent: `lines` tells it from a page past the end.
|
|
expect(await service.read(agent, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true })
|
|
})
|
|
|
|
it('returns an empty eof page for an offset past the last line', async () => {
|
|
await twentyLines()
|
|
const result = await endpoint().read(agent, 'long.txt', { offset: 21 }, signal())
|
|
expect(result).toMatchObject({ offset: 21, text: '', lines: 0, eof: true })
|
|
})
|
|
|
|
it('defaults the limit to the configured page size', async () => {
|
|
await twentyLines()
|
|
const result = await endpoint({ maxLines: 5 }).read(agent, 'long.txt', {}, signal())
|
|
expect(result.text.split('\n')).toHaveLength(5)
|
|
expect(result.eof).toBe(false)
|
|
})
|
|
|
|
it('refuses a limit above the configured page size and a non-positive-integer window', async () => {
|
|
await twentyLines()
|
|
const service = endpoint({ maxLines: 5 })
|
|
for (const range of [{ limit: 6 }, { offset: 0 }, { limit: 1.5 }, { offset: -3 }]) {
|
|
const failure = await failureOf(service.read(agent, 'long.txt', range, signal()))
|
|
expect(failure.code).toBe('gateway/bad-request')
|
|
}
|
|
})
|
|
|
|
it('keeps carriage returns: the page is the file text, not a rendering of it', async () => {
|
|
await writeFile(join(workspace, 'crlf.txt'), 'a\r\nb\r\n', 'utf8')
|
|
const result = await endpoint().read(agent, 'crlf.txt', {}, signal())
|
|
expect(result.text).toBe('a\r\nb\r')
|
|
})
|
|
})
|
|
|
|
describe('workspaceFiles.read — read access and file kinds', () => {
|
|
it('reads an absolute path outside the workspace without mutating it', async () => {
|
|
await writeFile(join(outside, 'notes.txt'), 'outside\nread only\n', 'utf8')
|
|
const write = vi.spyOn(harness.ctx.fs, 'writeText')
|
|
const edit = vi.spyOn(harness.ctx.fs, 'editText')
|
|
const result = await endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())
|
|
expect(result).toMatchObject({ text: 'outside\nread only', lines: 2, eof: true })
|
|
expect(write).not.toHaveBeenCalled()
|
|
expect(edit).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('resolves a relative file outside the workspace on the Host', async () => {
|
|
await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
|
|
expect(await endpoint().read(agent, '../outside/notes.txt', {}, signal())).toMatchObject({ text: 'outside', eof: true })
|
|
})
|
|
|
|
it('preserves a filesystem provider refusal for an outside file', async () => {
|
|
await writeFile(join(outside, 'notes.txt'), 'outside', 'utf8')
|
|
const refusal = new FsError('backend denied read', 'FS_SANDBOX_DENIED')
|
|
vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(refusal)
|
|
await expect(endpoint().read(agent, join(outside, 'notes.txt'), {}, signal())).rejects.toBe(refusal)
|
|
})
|
|
|
|
it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => {
|
|
await writeFile(join(outside, 'secret.txt'), 'no', 'utf8')
|
|
// The path itself is inside the workspace and would pass any string
|
|
// comparison; only lstat (before the follow) or realpath containment catches it.
|
|
await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt'))
|
|
const failure = await failureOf(endpoint().read(agent, 'link.txt', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/not-regular-file')
|
|
expect(failure.details).toMatchObject({ kind: 'symlink' })
|
|
})
|
|
|
|
it('rejects a symlink even when it points back inside the workspace', async () => {
|
|
await writeFile(join(workspace, 'real.txt'), 'fine', 'utf8')
|
|
await symlink(join(workspace, 'real.txt'), join(workspace, 'alias.txt'))
|
|
const failure = await failureOf(endpoint().read(agent, 'alias.txt', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/not-regular-file')
|
|
})
|
|
|
|
it('rejects a directory, which has no text to return', async () => {
|
|
await mkdir(join(workspace, 'src'), { recursive: true })
|
|
const failure = await failureOf(endpoint().read(agent, 'src', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/not-regular-file')
|
|
expect(failure.details).toMatchObject({ kind: 'directory' })
|
|
})
|
|
|
|
it('reports a missing path as not found', async () => {
|
|
const failure = await failureOf(endpoint().read(agent, 'nope.txt', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/not-found')
|
|
})
|
|
|
|
it('refuses an empty path as a bad request', async () => {
|
|
const failure = await failureOf(endpoint().read(agent, '', {}, signal()))
|
|
expect(failure.code).toBe('gateway/bad-request')
|
|
})
|
|
})
|
|
|
|
describe('workspaceFiles.read — gate 3: the page byte cap', () => {
|
|
it('fails a page above the cap rather than returning it shortened', async () => {
|
|
await writeFile(join(workspace, 'big.txt'), 'x'.repeat(4096), 'utf8')
|
|
const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(agent, 'big.txt', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/too-large')
|
|
expect(failure.details).toMatchObject({ limit: 1024 })
|
|
})
|
|
|
|
it('accepts a page exactly at the cap, because the cap is inclusive', async () => {
|
|
await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
|
|
const result = await endpoint({ maxBytes: 64 }).read(agent, 'exact.txt', {}, signal())
|
|
expect(result.text).toHaveLength(64)
|
|
})
|
|
|
|
it('counts the newlines between the page lines against the cap', async () => {
|
|
await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
|
|
const failure = await failureOf(endpoint({ maxBytes: 63 }).read(agent, 'exact.txt', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/too-large')
|
|
})
|
|
|
|
it('caps the page, not the file: a small window of a file far above the cap reads', async () => {
|
|
await writeFile(join(workspace, 'huge.txt'), Array.from({ length: 2000 }, (_, i) => `row ${i} ${'z'.repeat(100)}`).join('\n'), 'utf8')
|
|
const result = await endpoint({ maxBytes: 1024 }).read(agent, 'huge.txt', { offset: 1990, limit: 3 }, signal())
|
|
expect(result.text.split('\n')).toHaveLength(3)
|
|
expect(result.eof).toBe(false)
|
|
expect(result.bytes).toBeGreaterThan(200_000)
|
|
})
|
|
})
|
|
|
|
describe('workspaceFiles.read — gate 4: text only', () => {
|
|
it('rejects bytes that are not valid UTF-8', async () => {
|
|
await writeFile(join(workspace, 'bin.dat'), Buffer.from([0xff, 0xfe, 0xfd]))
|
|
const failure = await failureOf(endpoint().read(agent, 'bin.dat', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/not-text')
|
|
})
|
|
|
|
it('rejects a page that carries NUL bytes, wherever in the file the page lies', async () => {
|
|
await writeFile(join(workspace, 'nul.dat'), Buffer.from([0x61, 0x00, 0x62]))
|
|
const service = endpoint()
|
|
expect((await failureOf(service.read(agent, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text')
|
|
// Past the backend's own binary sample, so only the page scan can see it.
|
|
await lateNul()
|
|
expect((await failureOf(service.read(agent, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text')
|
|
})
|
|
|
|
it('reads a page that ends before a NUL byte, because detection is per page', async () => {
|
|
await lateNul()
|
|
const result = await endpoint().read(agent, 'late-nul.txt', { limit: 1 }, signal())
|
|
expect(result.text).toHaveLength(9000)
|
|
expect(result.eof).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('workspaceFiles.read — the file changing under its gate', () => {
|
|
/** Run `mutate` after the path gate has looked, so what follows sees a different filesystem. */
|
|
function afterGate(mutate: () => Promise<void>): void {
|
|
const fs = harness.ctx.fs
|
|
const lstat = fs.lstat.bind(fs)
|
|
vi.spyOn(fs, 'lstat').mockImplementation(async (path, opts, signal) => {
|
|
const entry = await lstat(path, opts, signal)
|
|
await mutate()
|
|
return entry
|
|
})
|
|
}
|
|
|
|
it('reports a file deleted after the gate as not found, not as an internal failure', async () => {
|
|
await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8')
|
|
afterGate(() => rm(join(workspace, 'fleeting.txt')))
|
|
const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/not-found')
|
|
})
|
|
|
|
it('reports a file replaced by a directory after the gate as not a regular file', async () => {
|
|
await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8')
|
|
afterGate(async () => {
|
|
await rm(join(workspace, 'fleeting.txt'))
|
|
await mkdir(join(workspace, 'fleeting.txt'))
|
|
})
|
|
const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal()))
|
|
expect(failure.code).toBe('workspace-file/not-regular-file')
|
|
expect(failure.details).toMatchObject({ kind: 'directory' })
|
|
})
|
|
|
|
it('passes any other backend failure through unchanged', async () => {
|
|
await writeFile(join(workspace, 'notes.txt'), 'x', 'utf8')
|
|
vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(new FsError('disk unreadable', 'FS_IO_ERROR'))
|
|
await expect(endpoint().read(agent, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
|
|
})
|
|
})
|