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

@@ -23,9 +23,9 @@ Field names are snake_case to match Claude Code and existing harness tool schema
## The tool is the executor; policy is an event gate
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then:
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash` (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits a contained `fs/observed`. (1 stat.)
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.)
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.)

View File

@@ -18,6 +18,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { sessionCwd } from './session-cwd.ts'
/** Validated `edit` arguments after defaulting. */
interface EditInput {
@@ -66,7 +67,8 @@ export function applyEditTool(ctx: Context): void {
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseEditArgs(args)
const target = await ctx.fs.resolve(input.filePath)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
// Single-slot decision: the policy plugin returns { version: vObserved } or
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
// No stat — the bare default never manufactures a version basis.

View File

@@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { buildWindow, formatReadOutput } from './read-render.ts'
import type { FileReadOutcome } from './read-render.ts'
import { sessionCwd } from './session-cwd.ts'
/** Default and maximum number of lines returned by one `read` call. */
export const READ_LIMIT = 2000
@@ -68,7 +69,8 @@ export function applyReadTool(ctx: Context): void {
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseReadArgs(args)
const target = await ctx.fs.resolve(input.filePath)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
// One stat: type check + size routing + the version recorded as observed.
// A writer racing between this stat and the read can at worst make a LATER

View File

@@ -0,0 +1,24 @@
/**
* Derive the working directory a filesystem tool resolves relative paths
* against: the calling agent's per-session workspace
* (`exec.agent.session.header.cwd`), so each ACP session's `read`/`write`/`edit`
* act on ITS workspace, not the server's launch dir — mirroring how
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
*
* The `agent` is optional-chained — a non-agent caller yields `undefined`, and
* the tool then calls `ctx.fs.resolve(path)` with no base so the backend applies
* its own configured default (preserving the non-ACP / no-session behavior).
* `session`/`header` are non-optional on a real `Agent`, so only `agent` needs
* the guard (mirroring `dsh-tool-bash`'s `resolveWorkdir`). Returning `undefined`
* rather than reading `process.cwd()` here keeps the default in ONE place (the
* provider), per the "explicit > implicit at seams" convention.
*
* @module @deepseek-ai/dsh-tool-fs/session-cwd
*/
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
/** The session workspace cwd for this call, or `undefined` when none applies. */
export function sessionCwd(exec: ToolExecution): string | undefined {
return exec.agent?.session.header.cwd
}

View File

@@ -17,6 +17,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { sessionCwd } from './session-cwd.ts'
/** Validate value constraints the schema DSL can't express. */
export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
@@ -51,7 +52,8 @@ export function applyWriteTool(ctx: Context): void {
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseWriteArgs(args)
const target = await ctx.fs.resolve(input.filePath)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
// Single-slot decision: the policy plugin produces createIfAbsent/
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)

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')
})
})

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' })
})