Drop the preflight sandbox fence. It derived writable roots in the local process, so under the SSH provider it threw on remote additionalWritableRoots and compared remote targets against local temp roots; write and edit never fenced in the tool layer. The provider now refuses a denied operation at commit, and a commit whose first operation failed without any change rejects like a preflight refusal so the sandbox escalation path keeps its error code. Classify FS_ABORTED as unknown: a cancelled remote request rejects while the provider may still be writing, so no stat comparison can settle it. Document that a cancelled call receives the registry's abort result like every tool. Store no diff for creations, matching write, and bound the move source basis by diffBasisMaxBytes so persisted metadata never holds whole files; FsMoveOutcome.sourceBefore becomes nullable. Omit deletions from the call preview and from result diffs without a basis instead of rendering them as empty creations, and count distinct files in the preview title. Reject a NUL byte beyond the read sampling window in preflight, name targets by display path in atomic-write diagnostics, align the apply_patch guidance with the edit wording and mention delete and move, read the stopped flag and applied diffs tool-agnostically in the client models, skip the permission-bit snapshot under root, list fs/delete-intent in the tool catalog, and refresh the fs-patch description, group README, fs-sandbox summary, and ui-tool README.
220 lines
12 KiB
TypeScript
220 lines
12 KiB
TypeScript
/** Sequential commits retain known effects and distinguish unchanged from uncertain failures. */
|
|
import { writeFile } from 'node:fs/promises'
|
|
import { resolve } from 'node:path'
|
|
import { FsError } from '@deepseek-ai/dsh-fs'
|
|
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
|
import { expect, it, vi } from 'vitest'
|
|
import { add, move, patch, remove, setup, update } from './harness.ts'
|
|
|
|
class LostReplyFileSystem extends LocalFileSystem {
|
|
override async patchText(...args: Parameters<LocalFileSystem['patchText']>): ReturnType<LocalFileSystem['patchText']> {
|
|
await super.patchText(...args)
|
|
throw new FsError('reply lost', 'FS_IO_ERROR')
|
|
}
|
|
}
|
|
|
|
it.each(['delete', 'move'] as const)('retains the first commit when %s removal fails and observes only published changes', async (kind) => {
|
|
const t = await setup()
|
|
await t.file('source', 'old\n')
|
|
const sourceGuard = await t.editIntent('source')
|
|
t.fs.internals.removeFile = async () => { throw Object.assign(new Error('removal denied'), { code: 'EACCES' }) }
|
|
const a = t.record('add', 'a')
|
|
const b = t.record(kind, 'source', 1, 4, kind === 'move' ? 'destination' : undefined)
|
|
const c = t.record('add', 'c', 2, kind === 'move' ? 9 : 5)
|
|
const destination = resolve(t.workspace, 'destination')
|
|
expect(await t.run(patch(add('a'), kind === 'move' ? move('source', 'destination') : remove('source'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
|
{ status: 'failed', operation: b, failure: { code: 'FS_PERMISSION_DENIED', message: `cannot delete "${b.path}": removal denied` },
|
|
changes: kind === 'move' ? [{ path: destination, before: null, after: 'new\n' }] : [] },
|
|
{ status: 'skipped', operation: c },
|
|
], affectedPaths: kind === 'move' ? [a.path, destination] : [a.path], cancelled: false })
|
|
expect(await t.bytes('a')).toBe('new\n')
|
|
expect(await t.bytes('source')).toBe('old\n')
|
|
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
|
expect(await t.editIntent('source')).toEqual(sourceGuard)
|
|
expect(t.observations.map(event => event.path)).toEqual(kind === 'move' ? [a.path, destination] : [a.path])
|
|
if (kind === 'move') {
|
|
expect(await t.bytes('destination')).toBe('new\n')
|
|
expect(await t.editIntent('destination')).toEqual({ version: await t.version('destination') })
|
|
}
|
|
})
|
|
|
|
it('stops between operations after cancellation without undoing the first write', async () => {
|
|
const t = await setup()
|
|
t.ctx.on('fs/observed', () => { t.controller.abort() })
|
|
const a = t.record('add', 'a')
|
|
expect(await t.run(patch(add('a'), add('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
|
{ status: 'skipped', operation: t.record('add', 'b', 1, 4) },
|
|
{ status: 'skipped', operation: t.record('add', 'c', 2, 6) },
|
|
], affectedPaths: [a.path], cancelled: true })
|
|
expect(await t.bytes('a')).toBe('new\n')
|
|
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
|
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
|
})
|
|
|
|
it('reports cancellation during staged writing as unknown without a stat comparison', async () => {
|
|
const t = await setup()
|
|
let writes = 0
|
|
t.fs.internals.inspectTemp = () => { if (++writes === 2) t.controller.abort() }
|
|
const a = t.record('add', 'a')
|
|
// The staged file never reaches its target, so a stat comparison would have called this failed.
|
|
expect(await t.run(patch(add('a'), add('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
|
{ status: 'unknown', operation: t.record('add', 'b', 1, 4), failure: { code: 'FS_ABORTED', message: 'write aborted — re-read the affected files before retrying' } },
|
|
{ status: 'skipped', operation: t.record('add', 'c', 2, 6) },
|
|
], affectedPaths: [a.path], cancelled: true })
|
|
expect(await t.bytes('a')).toBe('new\n')
|
|
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
|
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
|
})
|
|
|
|
it('does not mark a fully completed patch cancelled when its final observation aborts', async () => {
|
|
const t = await setup()
|
|
t.ctx.on('fs/observed', () => { t.controller.abort() })
|
|
const a = t.record('add', 'a')
|
|
expect(await t.run(patch(add('a')))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
|
], affectedPaths: [a.path], cancelled: false })
|
|
expect(await t.bytes('a')).toBe('new\n')
|
|
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
|
})
|
|
|
|
it('reports unknown after the provider changes disk and loses its reply without recording an observation', async () => {
|
|
const t = await setup({ provider: LostReplyFileSystem })
|
|
await t.file('a', 'old\n')
|
|
const observed = await t.editIntent('a')
|
|
expect(await t.run(patch(update('a'), add('b')))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'unknown', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'reply lost — re-read the affected files before retrying' } },
|
|
{ status: 'skipped', operation: t.record('add', 'b', 1, 6) },
|
|
], affectedPaths: [], cancelled: false })
|
|
expect(await t.bytes('a')).toBe('new\n')
|
|
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(t.observations).toEqual([])
|
|
expect(await t.editIntent('a')).toEqual(observed)
|
|
})
|
|
|
|
it.each(['unchanged', 'stat fails', 'stat defect'] as const)('verifies an I/O failure after provider entry: %s', async (scenario) => {
|
|
const t = await setup()
|
|
await t.file('a', 'old\n')
|
|
const observed = await t.editIntent('a')
|
|
const defect = new Error('stat defect')
|
|
vi.spyOn(t.fs, 'patchText').mockImplementation(async () => {
|
|
if (scenario !== 'unchanged') vi.spyOn(t.fs, 'stat').mockRejectedValue(scenario === 'stat fails' ? new FsError('stat failed', 'FS_IO_ERROR') : defect)
|
|
throw new FsError('write failed', 'FS_IO_ERROR')
|
|
})
|
|
const request = t.run(patch(update('a'), add('b')))
|
|
if (scenario === 'stat defect') await expect(request).rejects.toBe(defect)
|
|
else expect(await request).toEqual({ phase: 'commit', outcomes: [
|
|
scenario === 'unchanged'
|
|
? { status: 'failed', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'write failed' }, changes: [] }
|
|
: { status: 'unknown', operation: t.record('update', 'a'), failure: { code: 'FS_IO_ERROR', message: 'write failed — re-read the affected files before retrying' } },
|
|
{ status: 'skipped', operation: t.record('add', 'b', 1, 6) },
|
|
], affectedPaths: [], cancelled: false })
|
|
expect(await t.bytes('a')).toBe('old\n')
|
|
expect(t.observations).toEqual([])
|
|
expect(await t.editIntent('a')).toEqual(observed)
|
|
})
|
|
|
|
it('checks both move targets when its destination was published but the reply is lost', async () => {
|
|
const t = await setup()
|
|
await t.file('a', 'old\n')
|
|
const observed = await t.editIntent('a')
|
|
vi.spyOn(t.fs, 'moveFile').mockImplementation(async (_source, destination) => {
|
|
await writeFile(destination.targetKey, 'published\n')
|
|
throw new FsError('move reply lost', 'FS_IO_ERROR')
|
|
})
|
|
expect(await t.run(patch(move('a', 'b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'unknown', operation: t.record('move', 'a', 0, 2, 'b'), failure: { code: 'FS_IO_ERROR', message: 'move reply lost — re-read the affected files before retrying' } },
|
|
{ status: 'skipped', operation: t.record('add', 'c', 1, 7) },
|
|
], affectedPaths: [], cancelled: false })
|
|
expect(await t.bytes('a')).toBe('old\n')
|
|
expect(await t.bytes('b')).toBe('published\n')
|
|
expect(t.observations).toEqual([])
|
|
expect(await t.editIntent('a')).toEqual(observed)
|
|
await expect(t.editIntent('b')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
|
})
|
|
|
|
it('retains preceding changes when an external edit invalidates the next commit guard', async () => {
|
|
const t = await setup()
|
|
await t.file('b', 'old\n')
|
|
const observed = await t.editIntent('b')
|
|
const write = t.fs.writeText.bind(t.fs)
|
|
vi.spyOn(t.fs, 'writeText').mockImplementation(async (...args) => {
|
|
const result = await write(...args)
|
|
await writeFile(resolve(t.workspace, 'b'), 'external edit\n')
|
|
return result
|
|
})
|
|
const a = t.record('add', 'a')
|
|
const b = t.record('update', 'b', 1, 4)
|
|
expect(await t.run(patch(add('a'), update('b'), add('c')))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'completed', operation: a, changes: [{ path: a.path, before: null, after: 'new\n' }] },
|
|
{ status: 'failed', operation: b, failure: { code: 'FS_STALE_VERSION', message: expect.stringContaining('re-read the file, then retry') as string }, changes: [] },
|
|
{ status: 'skipped', operation: t.record('add', 'c', 2, 8) },
|
|
], affectedPaths: [a.path], cancelled: false })
|
|
expect(await t.bytes('a')).toBe('new\n')
|
|
expect(await t.bytes('b')).toBe('external edit\n')
|
|
await expect(t.bytes('c')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
|
expect(await t.editIntent('b')).toEqual(observed)
|
|
})
|
|
|
|
it('propagates programmer defects from mutation and preserves observed state', async () => {
|
|
const t = await setup()
|
|
await t.file('a', 'old\n')
|
|
const defect = new Error('mutation defect')
|
|
vi.spyOn(t.fs, 'patchText').mockRejectedValue(defect)
|
|
await expect(t.run(patch(update('a')))).rejects.toBe(defect)
|
|
expect(await t.bytes('a')).toBe('old\n')
|
|
expect(t.observations).toEqual([])
|
|
expect(await t.editIntent('a')).toEqual({ version: await t.version('a') })
|
|
})
|
|
|
|
it('reports the locked move source after an external edit following preflight', async () => {
|
|
const t = await setup({ policy: false })
|
|
await t.file('a', 'old\n')
|
|
const originalMove = t.fs.moveFile.bind(t.fs)
|
|
vi.spyOn(t.fs, 'moveFile').mockImplementation(async (...args) => {
|
|
await writeFile(resolve(t.workspace, 'a'), 'old\nexternal\n')
|
|
return originalMove(...args)
|
|
})
|
|
const reads = vi.spyOn(t.fs, 'readText')
|
|
expect(await t.run(patch(move('a', 'b')))).toMatchObject({ phase: 'commit', outcomes: [{ status: 'completed', operation: t.record('move', 'a', 0, 2, 'b'), changes: [
|
|
{ path: resolve(t.workspace, 'b'), before: null, after: 'new\nexternal\n' },
|
|
{ path: resolve(t.workspace, 'a'), before: 'old\nexternal\n', after: null },
|
|
] }] })
|
|
expect(reads).toHaveBeenCalledTimes(1)
|
|
expect(await t.bytes('b')).toBe('new\nexternal\n')
|
|
})
|
|
|
|
it('stops at the first provider sandbox denial under read-only policy', async () => {
|
|
const t = await setup({ mode: 'read-only' })
|
|
await t.file('a', 'old\n')
|
|
const b = t.record('add', 'b')
|
|
expect(await t.run(patch(add('b'), update('a'), remove('a')))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'failed', operation: b, failure: { code: 'FS_SANDBOX_DENIED', message: expect.stringContaining('[sandbox: file access denied under read-only mode]') as string }, changes: [] },
|
|
{ status: 'skipped', operation: t.record('update', 'a', 1, 4) },
|
|
{ status: 'skipped', operation: t.record('delete', 'a', 2, 8) },
|
|
], affectedPaths: [], cancelled: false })
|
|
expect(await t.bytes('a')).toBe('old\n')
|
|
await expect(t.bytes('b')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(t.observations).toEqual([])
|
|
})
|
|
|
|
it('retains earlier writes when the provider denies a move destination outside writable roots', async () => {
|
|
const t = await setup({ mode: 'workspace-write' })
|
|
await t.file('a', 'old\n')
|
|
const outside = resolve(process.cwd(), 'patch-commit-outside-root')
|
|
const b = t.record('add', 'b')
|
|
expect(await t.run(patch(add('b'), move('a', outside)))).toEqual({ phase: 'commit', outcomes: [
|
|
{ status: 'completed', operation: b, changes: [{ path: b.path, before: null, after: 'new\n' }] },
|
|
{ status: 'failed', operation: t.record('move', 'a', 1, 4, outside), failure: { code: 'FS_SANDBOX_DENIED', message: expect.stringContaining('[sandbox: file access denied under workspace-write mode]') as string }, changes: [] },
|
|
], affectedPaths: [b.path], cancelled: false })
|
|
expect(await t.bytes('a')).toBe('old\n')
|
|
expect(await t.bytes('b')).toBe('new\n')
|
|
expect(t.observations.map(event => event.path)).toEqual([b.path])
|
|
})
|