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
+48
View File
@@ -0,0 +1,48 @@
# @deepseek-ai/dsh-fs-policy
The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
```ts
import type { Context } from 'cordis'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
declare const ctx: Context
// No service to inject — this plugin only registers the three fs/* listeners.
// Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the
// @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin
// decides. Order does not matter for resolution (no inject), but the policy
// listener should be the first decider registered for the fs/*-intent slots.
await ctx.plugin(FsPolicy)
```
## The four-layer split
| Layer | Package | Role |
|---|---|---|
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` |
## How the gate participates
Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`):
| Event | This plugin's listener |
|---|---|
| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
## Observed state is the prior-observation record; freshness is provider CAS
Observed state is a `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — this plugin only supplies `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred.
## Single-slot, first-wins
The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`.
## No method coupling
Because the plugin influences the world only through events, removing it does not break `@deepseek-ai/dsh-tool-fs` at a service-injection boundary: the tool falls through to the bare `ctx.fs` provider (unconditional write/edit, no observed-state). Loading it back layers the policy on. That graceful add/remove is the whole point of the event gate over a mandatory method service.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@deepseek-ai/dsh-fs-policy",
"description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* The fs-policy PLUGIN: observed-state, read-before-edit, and
* "write/edit must be based on the version you read" — added on top of the
* `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method
* service. This plugin registers NO `ctx.fsPolicy` service and exposes no
* `read`/`write`/`edit`/`resolve` methods; it influences the world only by
* deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and
* recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs`
* (the executor) free of any method coupling to the policy layer — removing
* this plugin gracefully loses the policy and leaves the unconstrained bare
* provider, rather than breaking the tool at a service-injection boundary.
*
* ## Observed state IS the prior-observation record
*
* State lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry
* exists iff the owner has read, written, OR edited that target (every success
* emits `fs/observed`), so its presence means "this owner has observed this
* target at this version". This is what lets a create-then-edit or
* edit-then-edit sequence work without an intervening re-read: the mutation
* refreshes the recorded version to its own result. The owner is derived
* structurally from `{ agent?: { session? } }` and held weakly, so a collected
* session frees its state; disposal drops everything (HMR safety).
*
* ## Freshness via provider CAS, not stat
*
* This plugin does NO filesystem I/O. "Have you observed this file?" is a
* `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read
* still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same
* atomic lock that performs the mutation — this plugin only supplies the
* observed version as the CAS basis. Stat-ing and comparing here would open a
* TOCTOU gap the provider lock has to back up anyway, so it is deliberately
* avoided.
*
* ## Single-slot, first-wins
*
* The `fs/write-intent`/`fs/edit-intent` listeners do NOT call
* `next()`: each fully decides its single slot. The slot is first-wins by
* registration order — this plugin owning it is the default-deployment
* convention, not an event-enforced invariant (a decider registered before /
* `prepend`ed would win instead). This is not a composable authorization chain;
* layered permission/audit/sandbox interception belongs on `tools/execute`.
*
* @module @deepseek-ai/dsh-fs-policy
*/
import type { Context } from 'cordis'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsPolicyExec } from './types.ts'
export type { FsPolicyExec } from './types.ts'
/**
* Per-context observed-file state and the three `fs/*` decisions over it. One
* instance is created per `apply()` so disposal can drop all state for HMR.
*/
class ObservedStateGate {
/**
* Observed-file state, keyed first by the owner object (weakly held, so a
* collected session frees its state), then by {@link FsTarget.targetKey}. An
* entry's PRESENCE is the prior-observation record.
*/
private observed = new WeakMap<object, Map<string, FsVersion>>()
/**
* Derive the observed-state owner from the opaque event actor — normally the
* active agent session. `undefined` when no owner can be derived (e.g. a
* direct tool call with no agent); such calls read freely but cannot satisfy
* the write/edit prior-observation policy.
*/
private owner(actor: object | undefined): object | undefined {
return (actor as FsPolicyExec | undefined)?.agent?.session
}
private get(owner: object, targetKey: string): FsVersion | undefined {
return this.observed.get(owner)?.get(targetKey)
}
private set(owner: object, targetKey: string, version: FsVersion): void {
let byTarget = this.observed.get(owner)
if (!byTarget) {
byTarget = new Map()
this.observed.set(owner, byTarget)
}
byTarget.set(targetKey, version)
}
/** Drop all recorded state (HMR safety / disposal). */
clear(): void {
this.observed = new WeakMap()
}
/**
* Decide the write intent: no prior observation ⇒ `createIfAbsent` (only
* new files can be created blindly); a prior observation ⇒ `replaceIfVersion`
* at the observed version (existing files replaced only if unchanged).
*/
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
}
/**
* Decide the edit version guard: requires a prior observation by this owner
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
*/
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
if (!owner || !prior) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
return { version: prior }
}
/** Record a successful read/write/edit: this owner observed this target at this version. */
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
const owner = this.owner(actor)
if (owner) this.set(owner, target.targetKey, version)
}
}
/** Cordis plugin name used by loader diagnostics. */
export const name = 'fs-policy'
/**
* Register the three `fs/*` listeners. No `inject` — this plugin reads no
* services; it operates only on its own `WeakMap`. The waterfalls are unbound
* (the tool dispatches them with no `this`), so the listeners take the raw
* `(target, actor, next)` arguments.
*/
export function apply(ctx: Context): void {
const gate = new ObservedStateGate()
ctx.effect(() => () => {
// Drop all recorded state on disposal so a reloaded plugin starts clean
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the
// release observable and immediate for tests.
gate.clear()
}, 'fs-policy observed-state teardown')
// fs/write-intent: occupy the single decision slot — do NOT call next().
// Deferred through Promise.resolve().then so the declared Promise return type
// holds (a throw rejects, never escapes synchronously through the waterfall).
ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
// fs/edit-intent: occupy the single decision slot — do NOT call next().
// Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise
// the edit tool's `await ctx.waterfall(...)` surfaces as its isError result.
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
// fs/observed: synchronous, side-effect-only WeakMap write. The tool emits
// this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw —
// a throw would surface as the tool's isError result for a mutation that
// already succeeded. A WeakMap.set honors that contract.
ctx.on('fs/observed', (target, version, actor) => {
gate.observe(target, version, actor)
})
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Vocabulary for the fs-policy plugin: the minimal execution-context
* shape used to derive an observed-state owner by narrowing the opaque `object`
* actor the `fs/*` events carry.
*
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is
* re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state
* owner structure on top of it.
*
* @module @deepseek-ai/dsh-fs-policy/types
*/
/**
* Minimal structural view of a tool execution the policy plugin needs to derive
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies
* this shape, so the tool passes its `exec` straight through as the opaque
* `object` actor on the `fs/*` events; this plugin narrows that actor to this
* shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
*
* The owner is `agent.session` when present. It is treated as an opaque object
* identity (a `WeakMap` key); this package never reads any of its fields.
*/
export interface FsPolicyExec {
/** The agent on whose behalf the call runs, when there is one. */
agent?: {
/** The session that owns observed-file state, used as an opaque key. */
session?: object
}
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Tests for the fs-policy PLUGIN: it registers no service, only the
* three `fs/*` listeners. We dispatch those events directly (the unbound
* waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the
* decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread
* edit, observed-state-as-prior-observation (read/write/edit all record),
* multi-owner isolation, single-slot first-wins, and disposal/HMR release.
*
* No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only
* decides intents and records versions on its own WeakMap.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
function target(path: string): FsTarget {
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
}
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
/** Dispatch the write-intent waterfall with the bare default thunk. */
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
return ctx.waterfall('fs/write-intent', t, actor, () => undefined)
}
/** Dispatch the edit-intent waterfall with the bare default thunk. */
function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
return ctx.waterfall('fs/edit-intent', t, actor, () => undefined)
}
async function setup() {
const ctx = new Context()
const fiber = await ctx.plugin(FsPolicy)
return { ctx, fiber }
}
describe('registration / disposal', () => {
it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => {
const { ctx } = await setup()
expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined()
})
it('mounts with no inject (reads no services)', async () => {
// It mounts immediately even with nothing else in the context.
const ctx = new Context()
await ctx.plugin(FsPolicy)
// The listener is live: an unobserved write decides createIfAbsent.
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
})
})
describe('write-intent decision', () => {
it('an unobserved target decides createIfAbsent', async () => {
const { ctx } = await setup()
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
})
it('a no-owner actor decides createIfAbsent', async () => {
const { ctx } = await setup()
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
})
it('an observed target decides replaceIfVersion at the observed version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
})
})
describe('edit-intent decision', () => {
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
const { ctx } = await setup()
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('rejects an edit with no owner (cannot prove prior observation)', async () => {
const { ctx } = await setup()
await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('returns the observed version as the CAS basis after an observation', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
})
})
describe('observed-state is the prior-observation record', () => {
it('a read observation authorizes an in-place write at that version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
// A create records v1; the follow-up edit guards against v1 with no read.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
// The edit records v2; a second edit guards against v2.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
})
it('a no-owner observation records nothing', async () => {
const { ctx } = await setup()
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
// Still unobserved for any owner.
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
})
describe('multi-owner isolation', () => {
it('owner A observing does not grant owner B edit authority', async () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
})
it('each owner records its own observed version independently', async () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
})
describe('single-slot, first-wins', () => {
it('fully decides the slot without calling next() (the bare default is unreached)', async () => {
const { ctx } = await setup()
let defaultRan = false
const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => {
defaultRan = true
return undefined
})
expect(intent).toEqual({ kind: 'createIfAbsent' })
expect(defaultRan).toBe(false)
})
it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => {
const { ctx } = await setup()
let secondRan = false
// Registered after fs-policy, so it dispatches second; fs-policy does
// not call next(), so this never runs. (A decider registered BEFORE — or with
// prepend — would instead win: first-wins is by convention, not enforced.)
ctx.on('fs/edit-intent', () => {
secondRan = true
return Promise.resolve(undefined)
})
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
await editIntent(ctx, target('a.txt'), exec)
expect(secondRan).toBe(false)
})
})
describe('disposal releases recorded state (HMR safety)', () => {
it('a fresh plugin after disposal starts with no inherited state', async () => {
const ctx = new Context()
const exec = ownerExec({})
const fiber = await ctx.plugin(FsPolicy)
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
await fiber.dispose()
await ctx.plugin(FsPolicy)
// Same owner object, but state was released on disposal.
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('no listeners remain after disposal (the gate no longer decides)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(FsPolicy)
await fiber.dispose()
// With no listener, the waterfall falls through to the bare default.
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../llm/llm" },
{ "path": "../fs" }
]
}