fix(fs): address review — rename to dsh-fs-policy, fs/*-intent events, RFC currency, ENOTDIR

Rename per review naming decisions:
- package dsh-file-context → dsh-fs-policy (dir, package name, plugin name,
  tsconfig refs, importers, type-equiv manifest, generated catalog + module-graph)
- events fs/write-expectation → fs/write-intent, fs/edit-expectation → fs/edit-intent
  (fs/observed unchanged); type FsWriteExpectation → FsWriteIntent, "expectation"
  wording → "intent" throughout
- exported FileContextExec → FsPolicyExec

Make the implemented RFCs describe what shipped, not the superseded designs:
the 2026-06-17 capability-seam + tool-schemas RFCs no longer place policy on
ctx.fs or use full/partial-view authorization, and the fsspec RFC's ctx.fileContext
service prose is rewritten to the fs/* event-gate reality (freshness-based auth).
Sharpen docs/rfc/implemented/AGENTS.md: a rename is a fact to fix IN PLACE — the
"new RFC" escape hatch is for macro decision reversals only, not renames.

Code fixes from review:
- fsio.ts resolveLocalTarget/probe translate ENOTDIR (a parent path segment is a
  file) into the structured FsError taxonomy instead of leaking a raw Node error;
  resolve reports FS_NOT_FOUND, probe reports absent. Regression tests proven to
  fail on the unfixed code.
- tool-fs HMR test now asserts prompt sections (not just tool schemas) are
  withdrawn on disposal.
- fs/observed is a plain (unguarded) ctx.emit: correct the fs-policy comment,
  filesystem.md, and tool-fs module doc that wrongly claimed the tool "contains"
  a throwing listener; a throw surfaces as the tool's isError result.
- drop the false "loaded by the default product config" claim (no config wires
  the fs tools yet), the duplicate ctx.bash service-map row, the stale
  FileReadRequest catalog link-map entry, and the fs/fs README EOF blank line;
  correct the dsh-fs package.json description.
This commit is contained in:
Tianyi Cui
2026-07-02 03:12:38 +08:00
parent e4b4eaaf38
commit 30c1863755
42 changed files with 360 additions and 323 deletions

View File

@@ -3,7 +3,7 @@
* 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-file-context` policy gate plugin: read-before-
* - 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
@@ -22,7 +22,7 @@ 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 FileContext from '@deepseek-ai/dsh-file-context'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
let dir: string
@@ -53,14 +53,14 @@ afterEach(async () => {
// --------------------------------------------------------------------------
// DEFAULT deployment: the policy gate plugin is loaded.
// --------------------------------------------------------------------------
describe('default deployment (with dsh-file-context)', () => {
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(FileContext)
await ctx.plugin(FsPolicy)
fiber = await ctx.plugin(ToolFs)
})
@@ -228,7 +228,7 @@ describe('default deployment (with dsh-file-context)', () => {
// --------------------------------------------------------------------------
// BARE deployment: the tool suite WITHOUT the policy gate.
// --------------------------------------------------------------------------
describe('bare provider (no dsh-file-context)', () => {
describe('bare provider (no dsh-fs-policy)', () => {
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-'))
ctx = new Context()

View File

@@ -1,6 +1,6 @@
/**
* Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the
* REAL `@deepseek-ai/dsh-file-context` gate plugin (the genuine policy
* REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy
* collaborator, per the prefer-the-real-implementation rule) over a fake
* `ctx.fs` provider, so they verify schemas, argument validation, result
* formatting, FsError→isError propagation, and that each tool dispatches the
@@ -19,10 +19,10 @@ import type {
FsEditRequest,
FsInfo,
FsTarget,
FsWriteExpectation,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import * as FileContext from '@deepseek-ai/dsh-file-context'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
@@ -31,8 +31,8 @@ import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
class FakeFs extends FileSystem {
files = new Map<string, string>()
rejectWith?: FsError
writeExpectations: (FsWriteExpectation | undefined)[] = []
editExpectations: ({ version: FsVersion } | undefined)[] = []
writeIntents: (FsWriteIntent | undefined)[] = []
editIntents: ({ version: FsVersion } | undefined)[] = []
private throwIfArmed(): void {
if (this.rejectWith) throw this.rejectWith
@@ -54,16 +54,16 @@ class FakeFs extends FileSystem {
const content = this.files.get(target.targetKey) ?? ''
return (async function* () { yield content })()
}
override async writeText(target: FsTarget, content: string, expected?: FsWriteExpectation): Promise<FsWriteOutcome> {
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
this.throwIfArmed()
this.writeExpectations.push(expected)
this.writeIntents.push(expected)
const existed = this.files.has(target.targetKey)
this.files.set(target.targetKey, content)
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
}
override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
this.throwIfArmed()
this.editExpectations.push(expected)
this.editIntents.push(expected)
const content = this.files.get(target.targetKey) ?? ''
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
@@ -75,7 +75,7 @@ async function setup() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeFs)
await ctx.plugin(FileContext)
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
const fs = ctx.fs as FakeFs
return { ctx, fs }
@@ -122,11 +122,16 @@ describe('registration', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeFs)
await ctx.plugin(FileContext)
await ctx.plugin(FsPolicy)
const fiber = await ctx.plugin(ToolFs)
// Each tool contributes BOTH a schema and a prompt section; disposal must
// withdraw both, not just the schemas.
expect(ctx.tools.schemas()).toHaveLength(3)
const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort()
expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['tool:edit', 'tool:read', 'tool:write'])
await fiber.dispose()
expect(ctx.tools.schemas()).toHaveLength(0)
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0)
})
})
@@ -174,7 +179,7 @@ describe('read tool', () => {
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 })
expect(edited.isError).toBe(false)
expect(fs.editExpectations).toEqual([{ version: 'v1' }])
expect(fs.editIntents).toEqual([{ version: 'v1' }])
})
it('propagates FS_NOT_FOUND for an absent file', async () => {
@@ -257,7 +262,7 @@ describe('write tool', () => {
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: {} })
expect(result.isError).toBe(false)
expect(text(result)).toContain('Created file')
expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }])
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
})
it('rejects a blank file_path', async () => {