The ACP bridge gives each session its own workspace (SessionHeader.cwd), and
dsh-tool-bash already resolves a bash workdir against it. But ctx.fs.resolve(path)
took no caller context and dsh-fs-local resolved every relative path against a
fixed config.cwd (process.cwd() at plugin load) — so in the ACP demo `write
foo.txt` and `bash cat foo.txt` hit different directories the moment an editor
opens any project other than the server's launch dir.
Thread the session cwd into resolution, mirroring dsh-tool-bash: widen
FileSystem.resolve to resolve(path, opts?: { cwd?: string }); dsh-fs-local bases
a relative path on opts.cwd ?? config.cwd (absolute paths ignore it); the
read/write/edit tools derive it via a shared sessionCwd(exec) helper
(exec.agent?.session.header.cwd). The provider stays free of dsh-agent/dsh-session
— the tool projects exec → cwd and hands over a plain string, per the
explicit-at-seams convention. Backward compatible (the arg is optional).
Tests: fs-local resolve(path,{cwd}) bases relative on the passed cwd / ignores it
for absolute; tool integration writes/reads/edits in a session cwd != config.cwd
and verifies the file on disk (proven to fail on the pre-fix no-cwd path). Fakes
that stood in a bare {session:{}} now carry a header so sessionCwd doesn't throw.
RFC in docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md.
341 lines
16 KiB
TypeScript
341 lines
16 KiB
TypeScript
/**
|
|
* Integration tests: the real local backend (`dsh-fs-local`) plus the model
|
|
* tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()`
|
|
* so nothing bypasses the tool registry. Two deployments:
|
|
*
|
|
* - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before-
|
|
* write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits.
|
|
* - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to
|
|
* its undefined default, so write/edit are unconditional. This proves the
|
|
* tool carries no dependency on the policy plugin.
|
|
*
|
|
* These verify the WORLD — files are read back from disk and asserted
|
|
* byte-for-byte — not the tool's self-report.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Context } from 'cordis'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
|
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
|
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
|
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
|
|
|
let dir: string
|
|
let ctx: Context
|
|
let fiber: Awaited<ReturnType<Context['plugin']>>
|
|
// A stable session object stands in for an agent session (the file-state
|
|
// owner). It carries a `header` (no `cwd`) so `sessionCwd(exec)` resolves to
|
|
// `undefined` and the backend falls back to its configured cwd (= `dir`).
|
|
const session = { header: {} }
|
|
|
|
let callCounter = 0
|
|
function call(name: string, args: unknown) {
|
|
return ctx.tools.execute({
|
|
callId: CallId(`call-${++callCounter}`),
|
|
name,
|
|
arguments: args,
|
|
agent: { session } as never,
|
|
})
|
|
}
|
|
|
|
function text(result: { content: { type: string; text?: string }[] }): string {
|
|
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await fiber.dispose()
|
|
await rm(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// DEFAULT deployment: the policy gate plugin is loaded.
|
|
// --------------------------------------------------------------------------
|
|
describe('default deployment (with dsh-fs-policy)', () => {
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
|
|
ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
|
await ctx.plugin(FsPolicy)
|
|
fiber = await ctx.plugin(ToolFs)
|
|
})
|
|
|
|
describe('write → disk', () => {
|
|
it('creates a file with exactly the requested bytes', async () => {
|
|
const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n')
|
|
})
|
|
|
|
it('rejects overwriting an existing file without reading it first', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'original')
|
|
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
|
|
})
|
|
|
|
it('allows overwriting after a read', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'original')
|
|
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
|
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
|
|
})
|
|
|
|
it('rejects a full overwrite when the file changed since the read (stale)', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'original')
|
|
await call('read', { file_path: 'a.txt' })
|
|
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
|
|
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
|
})
|
|
})
|
|
|
|
describe('read', () => {
|
|
it('returns line-numbered content', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
|
const result = await call('read', { file_path: 'a.txt' })
|
|
expect(text(result)).toContain('1: alpha')
|
|
expect(text(result)).toContain('2: beta')
|
|
expect(text(result)).toContain('(End of file - total 2 lines)')
|
|
})
|
|
|
|
it('reports a binary file as an error', async () => {
|
|
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
|
|
const result = await call('read', { file_path: 'bin' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
|
|
})
|
|
|
|
it('paginates a multi-line file with offset/limit', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour')
|
|
const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 })
|
|
expect(text(result)).toContain('2: two')
|
|
expect(text(result)).toContain('3: three')
|
|
expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
|
|
})
|
|
})
|
|
|
|
describe('edit → disk', () => {
|
|
it('applies a unique literal replacement after a read', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'hello world')
|
|
await call('read', { file_path: 'a.txt' })
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
|
})
|
|
|
|
it('rejects an edit before any read, leaving the file untouched', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'hello world')
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
|
})
|
|
|
|
it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => {
|
|
// A file with more lines than the read window; read only the first line.
|
|
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`)
|
|
await writeFile(join(dir, 'a.txt'), lines.join('\n'))
|
|
const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
|
expect(read.isError).toBe(false)
|
|
expect(text(read)).toContain('(Showing lines 1-1 of 20')
|
|
|
|
// Editing a line OUTSIDE the window is authorized because the file is unchanged.
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n'))
|
|
})
|
|
|
|
it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'hello world')
|
|
await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
|
await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
|
})
|
|
|
|
it('rejects an ambiguous match without replace_all', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'a a a')
|
|
await call('read', { file_path: 'a.txt' })
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
|
|
})
|
|
|
|
it('replaces all matches with replace_all', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'a a a')
|
|
await call('read', { file_path: 'a.txt' })
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
|
})
|
|
|
|
it('supports a full write→edit cycle without an intervening read', async () => {
|
|
await call('write', { file_path: 'a.txt', content: 'one two' })
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three')
|
|
})
|
|
})
|
|
|
|
describe('the gate records only through the events (no method coupling)', () => {
|
|
it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'hello world')
|
|
// Reach AROUND the tool — an explicit escape hatch for non-tool consumers.
|
|
await ctx.fs.readText(await ctx.fs.resolve('a.txt'))
|
|
// The model-facing edit still rejects: the read did not emit fs/observed.
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
|
})
|
|
})
|
|
|
|
describe('stat budget', () => {
|
|
it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'hello world')
|
|
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
|
|
|
// read: exactly one stat (type + size routing + observed version).
|
|
await call('read', { file_path: 'a.txt' })
|
|
expect(statSpy).toHaveBeenCalledTimes(1)
|
|
|
|
// edit (guarded, after the read): the gate supplies vObserved; the tool
|
|
// does not stat to manufacture a basis. CAS happens in editText's lock.
|
|
statSpy.mockClear()
|
|
const edited = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
|
expect(edited.isError).toBe(false)
|
|
expect(statSpy).not.toHaveBeenCalled()
|
|
|
|
// write (guarded replace, after the edit refreshed observed state): zero stat.
|
|
statSpy.mockClear()
|
|
const written = await call('write', { file_path: 'a.txt', content: 'fresh' })
|
|
expect(written.isError).toBe(false)
|
|
expect(statSpy).not.toHaveBeenCalled()
|
|
statSpy.mockRestore()
|
|
})
|
|
})
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// BARE deployment: the tool suite WITHOUT the policy gate.
|
|
// --------------------------------------------------------------------------
|
|
describe('bare provider (no dsh-fs-policy)', () => {
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-'))
|
|
ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
|
fiber = await ctx.plugin(ToolFs)
|
|
})
|
|
|
|
it('read works (it never needed policy)', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
|
const result = await call('read', { file_path: 'a.txt' })
|
|
expect(result.isError).toBe(false)
|
|
expect(text(result)).toContain('1: alpha')
|
|
})
|
|
|
|
it('write unconditionally creates a new file', async () => {
|
|
const result = await call('write', { file_path: 'new.txt', content: 'fresh' })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
|
})
|
|
|
|
it('write unconditionally OVERWRITES an existing unread file', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'original')
|
|
const result = await call('write', { file_path: 'a.txt', content: 'clobbered' })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
|
|
})
|
|
|
|
it('edit unconditionally edits an UNREAD existing file', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'hello world')
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
|
expect(result.isError).toBe(false)
|
|
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
|
})
|
|
|
|
it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
|
|
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
|
})
|
|
|
|
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'hello world')
|
|
const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
|
|
expect(result.isError).toBe(true)
|
|
expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
|
})
|
|
|
|
it('neither write nor edit stats in the tool on the bare path', async () => {
|
|
await writeFile(join(dir, 'a.txt'), 'hello world')
|
|
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
|
expect((await call('write', { file_path: 'a.txt', content: 'x y' })).isError).toBe(false)
|
|
expect((await call('edit', { file_path: 'a.txt', old_string: 'y', new_string: 'z' })).isError).toBe(false)
|
|
expect(statSpy).not.toHaveBeenCalled()
|
|
statSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Per-session cwd: a relative file_path resolves against the CALLING session's
|
|
// workspace (`exec.agent.session.header.cwd`), NOT the backend's config.cwd —
|
|
// so an ACP editor's per-session dir wins, matching dsh-tool-bash. The regression
|
|
// this guards: before the seam fix the tool passed no cwd, so a relative write
|
|
// landed in config.cwd instead of the session dir.
|
|
// --------------------------------------------------------------------------
|
|
describe('per-session cwd', () => {
|
|
let sessionDir: string
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-cfg-'))
|
|
sessionDir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-session-'))
|
|
ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(LocalFileSystem, { cwd: dir }) // config.cwd = dir, NOT sessionDir
|
|
await ctx.plugin(FsPolicy)
|
|
fiber = await ctx.plugin(ToolFs)
|
|
})
|
|
afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }) })
|
|
|
|
const callIn = (sessionObj: object, name: string, args: unknown) =>
|
|
ctx.tools.execute({
|
|
callId: CallId(`call-${++callCounter}`),
|
|
name,
|
|
arguments: args,
|
|
agent: { session: sessionObj } as never,
|
|
})
|
|
|
|
it('writes a relative path into the SESSION cwd, not config.cwd', async () => {
|
|
const result = await callIn({ header: { cwd: sessionDir } }, 'write', { file_path: 'note.txt', content: 'hi' })
|
|
expect(result.isError).toBe(false)
|
|
// Verify the WORLD: the file is in the session dir, and NOT in config.cwd.
|
|
expect(await readFile(join(sessionDir, 'note.txt'), 'utf8')).toBe('hi')
|
|
await expect(readFile(join(dir, 'note.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
})
|
|
|
|
it('read + edit both resolve against the session cwd (end-to-end)', async () => {
|
|
// ONE session object across both calls — observed-state keys by owner
|
|
// identity, so read must record under the same owner the edit reads.
|
|
const session = { header: { cwd: sessionDir } }
|
|
await writeFile(join(sessionDir, 'code.txt'), 'alpha')
|
|
expect((await callIn(session, 'read', { file_path: 'code.txt' })).isError).toBe(false)
|
|
const edited = await callIn(session, 'edit', { file_path: 'code.txt', old_string: 'alpha', new_string: 'beta' })
|
|
expect(edited.isError).toBe(false)
|
|
expect(await readFile(join(sessionDir, 'code.txt'), 'utf8')).toBe('beta')
|
|
})
|
|
})
|