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

View File

@@ -51,6 +51,30 @@ describe('registration', () => {
})
})
describe('resolve', () => {
it('resolves a relative path against opts.cwd, not config.cwd', async () => {
// config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative
// path there (the per-session-workspace seam — mirrors tool-bash workdir).
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
try {
await writeFile(join(other, 'x.txt'), 'in other')
const viaOther = await fs.resolve('x.txt', { cwd: other })
expect(await fs.readText(viaOther)).toBe('in other')
// Same relative path with no opts falls back to config.cwd (= dir), where
// x.txt does not exist.
await expect(fs.readText(await fs.resolve('x.txt'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
} finally {
await rm(other, { recursive: true, force: true })
}
})
it('ignores opts.cwd for an ABSOLUTE path', async () => {
await writeFile(join(dir, 'abs.txt'), 'absolute')
const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
expect(await fs.readText(target)).toBe('absolute')
})
})
describe('stat', () => {
it('returns file metadata, directory type, and undefined for absent', async () => {
await writeFile(join(dir, 'a.txt'), 'hello')