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:
Tianyi Cui
2026-07-02 18:29:29 +08:00
parent b06f1bb60d
commit 743eb9ea09
15 changed files with 161 additions and 17 deletions
+53 -2
View File
@@ -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')
})
})
+4 -4
View File
@@ -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' })
})