The filesystem seam
packages/fs/fs defines ctx.fs, the FileSystem abstract service. Unlike the shell seam, ctx.fs sits behind several providers across two worlds: local providers (fs-local, fs-sandbox) and a remote E2B Linux world (fs-e2b, backed by ctx.e2b). The observation policy (fs-observation-policy) is an event-only companion plugin that registers no service — it contributes guarded mutation semantics through the fs/* event gate.
tool-fs ── tool-fs-search ── tool-str-replace-editor (CONSUMERS)
│
┌──────────────▼───────────────┐
│ Service Definition: ctx.fs │
│ FileSystem (abstract) │ @deepseek-ai/dsh-fs
└──────────────┬───────────────┘
┌─────────────┼────────────────────┐
▼ ▼ ▼
fs-local fs-sandbox fs-e2b (PROVIDERS)
(bare local) (sandbox-fenced) (E2B remote, via ctx.e2b)
└── event gate ──► fs-observation-policy (companion, no service)Package versions
| Package | Role |
|---|---|
@deepseek-ai/dsh-fs | Service Definition (FileSystem, ctx.fs, FsError) |
@deepseek-ai/dsh-fs-local | Provider: bare local backend |
@deepseek-ai/dsh-fs-sandbox | Provider: local backend fencing mutations by sandbox mode |
@deepseek-ai/dsh-fs-observation-policy | Companion: event-only observed-state guards |
@deepseek-ai/dsh-tool-fs | Consumer: read/read_image/write/edit tools |
@deepseek-ai/dsh-tool-fs-search | Consumer: glob + grep search tools |
@deepseek-ai/dsh-tool-str-replace-editor | Consumer: str_replace_editor tool |
@deepseek-ai/dsh-workspace | Core: ctx.workspaceRegistry + workspace entity |
@deepseek-ai/dsh-atomic-write | Util: atomic file replacement + writer lock |
@deepseek-ai/dsh-fs-e2b | Provider: E2B filesystem backend (see Code Runtime page) |
The FS service surface
FileSystem (packages/fs/fs/src/index.ts) is the abstract class behind ctx.fs. Every operation takes a resolved FsTarget — the opaque identity resolve() returns — and I/O is async so a remote backend can round-trip to map a path to a stable targetKey.
export abstract class FileSystem extends Service {
constructor(ctx: Context) { super(ctx, 'fs') }
get sandboxMode(): SandboxMode | undefined { return undefined }
abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
abstract processPath(target: FsTarget): string // canonical absolute path in this execution world
abstract fileUrl(target: FsTarget): string // canonical file: URI
abstract contains(parent: FsTarget, child: FsTarget): boolean
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined>
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
abstract writeText(target, content, expected?, signal?, sandboxPolicy?): Promise<FsWriteOutcome>
abstract editText(target, edit, expected?, signal?, sandboxPolicy?): Promise<FsEditOutcome>
}Key design points:
targetKeyis opaque (FsTargetKeybrand): consumers must not parse it or assume it is a local absolute path.processPath/fileUrlare the separate backends-controlled ways to hand a value to another OS capability.statreturns metadata only (FsInfo:version,type: 'file'|'directory'|'other', optionalsize). The policy layer usestypeandsizeto choosereadTextvsstreamTextwithout probing by failure.lstatis path-shaped, not target-shaped: it does not follow the final path component, soFsPathInfo.typecan report'symlink', letting a trust-boundary consumer reject repository-owned links beforeresolvefollows them.- Mutation is atomic and opt-in guarded (
expectedintent / version), plus asandboxPolicyfence argument.
The opaque FsVersion and FsWriteIntent
Guards are token-based, never "compare-by-content-permission":
export type FsWriteIntent =
| { kind: 'createIfAbsent' } // rejects existing → FS_NOT_OBSERVED
| { kind: 'replaceIfVersion'; version: FsVersion } // rejects absence/mismatch → FS_STALE_VERSION
// omitting the intent = unconditional create-or-overwrite (not a third arm)FsVersion is a branded opaque token derived from high-resolution stat identity + freshness. Mutations that accept a version guard reject stale content with FS_STALE_VERSION before matching.
The typed error taxonomy
FsError extends HarnessError carries a stable machine-routable FsErrorCode:
FS_NOT_FOUND, FS_NOT_DIRECTORY, FS_NOT_TEXT, FS_NOT_REGULAR_FILE, FS_TOO_LARGE, FS_PERMISSION_DENIED, FS_SANDBOX_DENIED, FS_IO_ERROR, FS_STALE_VERSION, FS_NOT_OBSERVED, FS_AMBIGUOUS_EDIT, FS_EDIT_NOT_FOUND, FS_ABORTED.
Observation policy: the fs/* event gate
fs-observation-policy (packages/fs/fs-observation-policy/src/index.ts) registers no service. It attaches three listeners to the event names declared on Context['Events'] in @deepseek-ai/dsh-fs:
| Event | Mode | What the policy does |
|---|---|---|
fs/write-intent | waterfall | derive the guard: unseen/absent → createIfAbsent; present → replaceIfVersion |
fs/edit-intent | waterfall | derive the version: unseen → FS_NOT_OBSERVED; confirmed absent → FS_NOT_FOUND |
fs/observed | emit | record an authoritative present/absent observation keyed by (owner, targetKey) |
State is a WeakMap<owner, Map<targetKey, FsObservation>>, keyed by the observed owner — derived from the opaque event actor (normally the active agent session), so a collected session frees its state. Without this plugin, the tools retain the bare provider's unconditional mutation behavior — that is the whole point of the seam split: the provider stays simple, and the "must read before write/edit" discipline is contributed by a companion.
This is the mechanism behind the model guidance encoded in the write/edit tool descriptions: "Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it)."
Providers: local, sandboxed, remote
fs-local— bare local backend: normalizes + realpaths, decodes UTF-8, rejects binaries (FS_NOT_TEXT), lists in stable name order, performs atomic mutations.editTextremains here so version check, literal match, and rewrite share one critical section.fs-sandbox(@deepseek-ai/dsh-fs-sandbox) — wraps the local backend and fences mutations by the shared sandbox mode fromctx.sandboxPolicy(containment.ts). Reads are not fenced; only write/edit carry thesandboxPolicyargument, sodanger-full-accessvsworkspace-writechanges what may be written, not what may be read. Denials surface asFS_SANDBOX_DENIED(mapped to the[sandbox: …]marker for the model).fs-e2b— an E2B-backed provider that runs a remote Linux filesystem world (see Code Runtime). Becausectx.fsandctx.subprocess/ctx.e2bshare one execution world,processPathreturns a path valid inside that sandbox.
The model-facing tools
tool-fs: read / read_image / write / edit
@deepseek-ai/dsh-tool-fs (package config: readLimit, readMaxLineLength, readMaxBytes, readStreamMinSize) registers the filesystem suite. read_image is composition-conditional: it only registers while the attachments store is mounted.
read — file_path, optional offset (1-based, default 1) and limit. Results include line numbers and a window { offset, limit, maxLineLength, maxBytes }.
write — file_path + content (an empty content writes an empty file), plus escalation fields (sandbox_permissions, justification) advertised only under a confining ctx.fs. Output returns { path, operation: 'create'|'update', before, after }; before is the LF-normalized pre-write basis so the UI computes a real diff. Presentation is a diff card.
edit — file_path, old_string, new_string, optional replace_all. The description enforces: old_string must match exactly, new_string empty deletes the match, and by default old_string must appear exactly once (ambiguity → FS_AMBIGUOUS_EDIT). replace_all: true replaces every match.
tool-fs-search: glob + grep
@deepseek-ai/dsh-tool-fs-search runs ripgrep as a child of the subprocess seam (it does not use ctx.fs). Both tools bound output through @deepseek-ai/dsh-output-retention (ItemRetainer head) and spill the complete result to a file when capped.
glob — pattern + optional path. Up to caps.maxResults paths come back in modification-time order; hidden and ignored files are included, VCS metadata dirs excluded; a larger result is sampled or the head returned and the complete sorted list saved. Output: { root, paths: string[] }.
grep — pattern (ripgrep syntax), optional path and include glob filter. Returns { matches: [{ path, lineNumber, line }] }, grouped by file in presentation. A capped result reports where the full match list was saved.
tool-str-replace-editor: the compatibility editor
@deepseek-ai/dsh-tool-str-replace-editor registers a single str_replace_editor tool (an Anthropic-style string-and-line editor). Its command enum is view | create | str_replace | insert, with parameters path (plus file_text, old_str, new_str, insert_line depending on command). Per its default description: view shows a file through cat -n or lists a directory up to 2 levels deep; create refuses an existing file; str_replace requires an exact multi-line match that is unique in the file; insert inserts after a line. Long output is truncated and marked <response clipped>. It routes through ctx.fs and the same sandbox escalation API with sandboxDenialMarker.
The workspace concept
The workspace package (packages/workspace/workspace) is not a ctx.fs provider — it is a durable registry of workspace records (ctx.workspaceRegistry) over the storage-domain facility. Key points:
realpathNormalize(inpackages/workspace/workspace/src/paths.ts) is the canonicalization the fs layer reuses for session cwd → workspace root.WorkspaceId-branded records give stable session identity for Host RPC and GUI projections (apps/web).tool-bash/tool-fsresolve a relativeworkdir/file_pathsession-workspace-relative; the sandbox-policy workspace root wins soworkdirand confinement use the exact same per-call identity.- Migration/heading rules: only trusted roots are accepted; a workspace record is "header-validated" against
SessionHeader.
Where files actually live is provider + composition dependent: local providers resolve against the real local filesystem; the workspace root is the canonical base for relative paths; a sandboxed or E2B composition confines (or relocates) the mutating face.
Atomic writes
@deepseek-ai/dsh-atomic-write (packages/util/atomic-write) is the zero-dependency primitive behind atomic file replacement:
writeFileAtomic(filename, content, { mode, dirMode? })— writes to a random-suffix sibling with exclusive create (wx), thenrenames over the target. Readers observe either the old or the new complete content; the fresh inode carriesoptions.modethrough the rename; the same-directory sibling keeps the rename on one filesystem. On failure the temp file is removed and the error rethrown.withFileLock— serializes cross-process writers of one file through awx-created<file>.locksibling, so a read-modify-write cycle can never resurrect a state another writer just replaced. Readers stay lock-free because the rename commit is atomic.
Note that the fs providers use their own atomicity (temp + rename), while this util serves harness-internal state files (settings, session docs).
Further reading
- Shell & Terminal — the sibling
ctx.shellseam with the same provider pattern. - Code Runtime — the E2B substrate behind
fs-e2b. - Filesystem Observation & Sandbox Policy — the mode fence and observed-state policy in security context.
- Sandbox Architecture — the three modes and the enforcement machines.
- Spill: Context Overflow — how oversized
glob/grepresults are spilled to files. packages/fs/fs/src/index.ts— theFileSystemdefinition.