fix(fs): resolve paths against the caller's session cwd
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.
This commit is contained in:
@@ -28,8 +28,10 @@ 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).
|
||||
const session = {}
|
||||
// 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) {
|
||||
@@ -287,3 +289,52 @@ describe('bare provider (no dsh-fs-policy)', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('read tool', () => {
|
||||
|
||||
it('records observed state so a follow-up edit by the same session is authorized', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = {}
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false)
|
||||
const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session })
|
||||
@@ -259,7 +259,7 @@ describe('formatReadOutput footer variants', () => {
|
||||
describe('write tool', () => {
|
||||
it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: {} })
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('Created file')
|
||||
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
|
||||
@@ -284,7 +284,7 @@ describe('write tool', () => {
|
||||
describe('edit tool', () => {
|
||||
it('formats a single-replacement success after a read', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = {}
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
|
||||
@@ -315,7 +315,7 @@ describe('edit tool', () => {
|
||||
it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: {} })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user