Skip to content

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.

text
          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

PackageRole
@deepseek-ai/dsh-fsService Definition (FileSystem, ctx.fs, FsError)
@deepseek-ai/dsh-fs-localProvider: bare local backend
@deepseek-ai/dsh-fs-sandboxProvider: local backend fencing mutations by sandbox mode
@deepseek-ai/dsh-fs-observation-policyCompanion: event-only observed-state guards
@deepseek-ai/dsh-tool-fsConsumer: read/read_image/write/edit tools
@deepseek-ai/dsh-tool-fs-searchConsumer: glob + grep search tools
@deepseek-ai/dsh-tool-str-replace-editorConsumer: str_replace_editor tool
@deepseek-ai/dsh-workspaceCore: ctx.workspaceRegistry + workspace entity
@deepseek-ai/dsh-atomic-writeUtil: atomic file replacement + writer lock
@deepseek-ai/dsh-fs-e2bProvider: 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.

ts
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:

  • targetKey is opaque (FsTargetKey brand): consumers must not parse it or assume it is a local absolute path. processPath/fileUrl are the separate backends-controlled ways to hand a value to another OS capability.
  • stat returns metadata only (FsInfo: version, type: 'file'|'directory'|'other', optional size). The policy layer uses type and size to choose readText vs streamText without probing by failure.
  • lstat is path-shaped, not target-shaped: it does not follow the final path component, so FsPathInfo.type can report 'symlink', letting a trust-boundary consumer reject repository-owned links before resolve follows them.
  • Mutation is atomic and opt-in guarded (expected intent / version), plus a sandboxPolicy fence argument.

The opaque FsVersion and FsWriteIntent

Guards are token-based, never "compare-by-content-permission":

ts
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:

EventModeWhat the policy does
fs/write-intentwaterfallderive the guard: unseen/absent → createIfAbsent; present → replaceIfVersion
fs/edit-intentwaterfallderive the version: unseen → FS_NOT_OBSERVED; confirmed absent → FS_NOT_FOUND
fs/observedemitrecord 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. editText remains 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 from ctx.sandboxPolicy (containment.ts). Reads are not fenced; only write/edit carry the sandboxPolicy argument, so danger-full-access vs workspace-write changes what may be written, not what may be read. Denials surface as FS_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). Because ctx.fs and ctx.subprocess/ctx.e2b share one execution world, processPath returns 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.

readfile_path, optional offset (1-based, default 1) and limit. Results include line numbers and a window { offset, limit, maxLineLength, maxBytes }.

writefile_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.

editfile_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.

globpattern + 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[] }.

greppattern (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 (in packages/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-fs resolve a relative workdir/file_path session-workspace-relative; the sandbox-policy workspace root wins so workdir and 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), then renames over the target. Readers observe either the old or the new complete content; the fresh inode carries options.mode through 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 a wx-created <file>.lock sibling, 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