From 5e01564afbbfa0bcc634e78d24e58f97a7337c86 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 22 Jun 2026 10:48:41 +0800 Subject: [PATCH] Add filesystem capability seam and tools --- docs/architecture.md | 6 + docs/cordis-catalog/events-and-services.md | 28 +- docs/module-graph.md | 9 + docs/rfc/README.md | 2 + .../2026-06-17-filesystem-capability-seam.md | 182 +++++++ .../2026-06-17-filesystem-tool-schemas.md | 113 +++++ packages/README.md | 7 + packages/fs/README.md | 11 + packages/fs/fs-local/README.md | 23 + packages/fs/fs-local/package.json | 34 ++ packages/fs/fs-local/src/fsio.ts | 470 ++++++++++++++++++ packages/fs/fs-local/src/index.ts | 197 ++++++++ packages/fs/fs-local/tests/filesystem.spec.ts | 268 ++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 364 ++++++++++++++ packages/fs/fs-local/tsconfig.json | 15 + packages/fs/fs/README.md | 38 ++ packages/fs/fs/package.json | 30 ++ packages/fs/fs/src/index.ts | 256 ++++++++++ packages/fs/fs/src/types.ts | 194 ++++++++ packages/fs/fs/tests/service.spec.ts | 313 ++++++++++++ packages/fs/fs/tsconfig.json | 13 + packages/fs/tool-fs/README.md | 33 ++ packages/fs/tool-fs/package.json | 51 ++ packages/fs/tool-fs/src/edit.ts | 82 +++ packages/fs/tool-fs/src/index.ts | 35 ++ packages/fs/tool-fs/src/read.ts | 95 ++++ packages/fs/tool-fs/src/write.ts | 63 +++ packages/fs/tool-fs/tests/integration.spec.ts | 143 ++++++ packages/fs/tool-fs/tests/subpaths.spec.ts | 74 +++ packages/fs/tool-fs/tests/tools.spec.ts | 270 ++++++++++ packages/fs/tool-fs/tsconfig.json | 16 + packages/fs/tool-fs/tsdown.config.ts | 18 + pnpm-lock.yaml | 52 ++ tsconfig.base.json | 4 + tsconfig.build.json | 3 + tsconfig.typecheck.json | 4 + 36 files changed, 3515 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md create mode 100644 docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md create mode 100644 packages/fs/README.md create mode 100644 packages/fs/fs-local/README.md create mode 100644 packages/fs/fs-local/package.json create mode 100644 packages/fs/fs-local/src/fsio.ts create mode 100644 packages/fs/fs-local/src/index.ts create mode 100644 packages/fs/fs-local/tests/filesystem.spec.ts create mode 100644 packages/fs/fs-local/tests/fsio.spec.ts create mode 100644 packages/fs/fs-local/tsconfig.json create mode 100644 packages/fs/fs/README.md create mode 100644 packages/fs/fs/package.json create mode 100644 packages/fs/fs/src/index.ts create mode 100644 packages/fs/fs/src/types.ts create mode 100644 packages/fs/fs/tests/service.spec.ts create mode 100644 packages/fs/fs/tsconfig.json create mode 100644 packages/fs/tool-fs/README.md create mode 100644 packages/fs/tool-fs/package.json create mode 100644 packages/fs/tool-fs/src/edit.ts create mode 100644 packages/fs/tool-fs/src/index.ts create mode 100644 packages/fs/tool-fs/src/read.ts create mode 100644 packages/fs/tool-fs/src/write.ts create mode 100644 packages/fs/tool-fs/tests/integration.spec.ts create mode 100644 packages/fs/tool-fs/tests/subpaths.spec.ts create mode 100644 packages/fs/tool-fs/tests/tools.spec.ts create mode 100644 packages/fs/tool-fs/tsconfig.json create mode 100644 packages/fs/tool-fs/tsdown.config.ts diff --git a/docs/architecture.md b/docs/architecture.md index 2b05e613b1..8f78a0b086 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,8 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-agent-loop (the ONE concrete plugin) │ │ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ +│ @deepseek-ai/dsh-fs-local (filesystem impl) │ +│ @deepseek-ai/dsh-tool-fs (filesystem tool schemas) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ │ @deepseek-ai/dsh-agent (vocabulary + registry) │ @@ -33,6 +35,7 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-session-persistence (persistence seam) │ │ @deepseek-ai/dsh-llm (abstract model service) │ │ @deepseek-ai/dsh-bash (abstract bash executor) │ +│ @deepseek-ai/dsh-fs (abstract filesystem) │ ├─────────────────────────────────────────────────────────────┤ │ vendor/: cordis, loader, include, group, timer, hmr, │ │ logger-console, cosmokit, schemastery │ @@ -53,6 +56,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) | | `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops | | `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks | +| `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem seam: path resolution, text reads, writes, edits, and observed-file policy | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -68,6 +72,8 @@ Swappable capabilities are split into **three packages** so each part evolves in The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise. +The filesystem capability follows the bash topology: `dsh-fs` owns the abstract `ctx.fs` service and observed-file policy, `dsh-fs-local` provides the local backend, and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over the interface. + > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations. ## The vocabulary (dsh-llm) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 083d3295f6..a64e962098 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -279,7 +279,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 8 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -339,6 +339,32 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) +### `ctx.fs` — `FileSystem` (abstract seam) + +Abstract filesystem service. Subclass, implement the four backend primitives (resolve, readPage, createOrReplace, applyEdit), and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). + +Consumers call the concrete public API (read/write/ edit), which derives the file-state owner, enforces the read-before-write/edit policy, and refreshes recorded state — then delegates the actual I/O to the backend primitives. + +Semantics every backend must honor: + +- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and file-state lookup agree across paths (e.g. through symlinks). +- readPage returns line-numbered UTF-8 content with a `version` and a `view` (`full` only when the page covered the whole file). +- createOrReplace honors the FsExpectation: `observed` rejects with `FS_STALE_VERSION` if the file changed since `version`; `partial` rejects existing targets because the owner saw only a non-editable view; `unobserved` creates iff the target is absent and otherwise rejects. +- applyEdit verifies the expected version (stale guard) and is atomic (read-modify-write must not interleave with a concurrent edit). + +```ts cordis-catalog +abstract resolve(path: string): Promise +abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise +abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise +abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise +owner(exec?: FsExecContext): object | undefined +async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise +async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise +async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise +``` + +Source: [`packages/fs/fs/src/index.ts:94`](../../packages/fs/fs/src/index.ts) + ### `ctx.llm` — `LlmService` The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9efe6e9669..5a69585ef8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ graph TD bash --> brand llm --> brand bash-local --> bash + fs --> llm llm-deepseek --> llm llm-pi-ai --> llm session --> brand @@ -18,6 +19,7 @@ graph TD agent --> brand agent --> llm agent --> session + fs-local --> fs llm-replay --> llm llm-replay --> session session-persistence --> session @@ -49,6 +51,10 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools + tool-fs --> fs + tool-fs --> llm + tool-fs --> system-prompt + tool-fs --> tools agent-core --> agent agent-core --> agent-loop agent-core --> invariants @@ -73,11 +79,13 @@ graph TD | `bash` | `brand` | | `llm` | `brand` | | `bash-local` | `bash` | +| `fs` | `llm` | | `llm-deepseek` | `llm` | | `llm-pi-ai` | `llm` | | `session` | `brand`, `llm` | | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | +| `fs-local` | `fs` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `invariants` | `agent`, `llm`, `session` | @@ -88,6 +96,7 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 4eb7900276..7f56757be0 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -81,6 +81,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| +| [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 | | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | ### Simplification @@ -110,6 +111,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 | | [Session persistence as an abstract service over `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 | | [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | +| [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 | | [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md new file mode 100644 index 0000000000..55f5efe14b --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -0,0 +1,182 @@ +# RFC: Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools + +Status: implemented + +## Problem + +The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations are about to be added as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly use `node:fs`, the model-facing tool package will own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once. + +That couples three concerns that change independently: + +1. The filesystem contract: what operations plugins can ask for. +2. The backend: local disk now, sandboxed/remote/project-scoped filesystem later. +3. The consumer surface: model-facing `read` / `write` / `edit` schemas and result formatting. + +Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed or remote backend would churn the tool schemas, demos, and prompt guidance even when the model-facing contract should stay stable. It also makes permission/sandbox boundaries harder to reason about: a `cwd` option can look like a sandbox even though it is only a base path unless an explicit backend or `tools/execute` policy enforces containment. + +We need the filesystem tools to land in the same capability-seam shape as bash before they become a public package surface. + +## Proposal + +Introduce filesystem access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): + +1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, filesystem vocabulary types, and file-state tracking contract. +2. `@deepseek-ai/dsh-fs-local` (`packages/fs/fs-local`) provides the first implementation, backed by the local filesystem. +3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. + +The consumer package depends only on the interface package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance. + +The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface. + +The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`. + +Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer. + +Read-before-write/edit is part of the filesystem seam, not a separate service. `ctx.fs` records which file states the current execution context has seen and validates write-like operations against that state. The first `tool-fs` consumer passes the current tool execution context, or a structural projection of it, through to `ctx.fs`; `ctx.fs` derives the file-state owner from that context, normally `exec.agent.session`. `tool-fs` does not know the cache shape, the owner key, or the `read` tool name/schema. + +## Package topology + +The filesystem seam uses the same dependency direction as the bash trio: + +```text +@deepseek-ai/dsh-tool-fs --depends on--> @deepseek-ai/dsh-fs <--depends on-- @deepseek-ai/dsh-fs-local + consumer interface implementation +``` + +`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the file-state contract. The interface defines a minimal structural execution context shape rather than importing `dsh-tools`, `dsh-agent`, or `dsh-session`; the implementation derives a file-state owner from that shape when one is available. The owner object is opaque to `dsh-fs`: `tool-fs` may pass the `ToolExecution` it already receives, or a projected object containing only the owner-bearing fields, without making `dsh-fs` depend on the tool or agent packages. + +`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, contains all direct `node:fs` / `node:path` access, and provides the in-memory file-state store for the local backend. + +`@deepseek-ai/dsh-tool-fs` depends on `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `cordis`. It registers model-facing tools and prompt sections. It must not import `node:fs`, `node:path`, or `@deepseek-ai/dsh-fs-local`; filesystem execution always goes through `ctx.fs`. If the implementation needs concrete agent or session helper types, those dependencies belong in `tool-fs`; they must not leak back into `dsh-fs`. + +The root `tool-fs` plugin registers the full filesystem tool suite by composing the per-tool registration helpers (`read`, `write`, and `edit`). The same helpers are exposed as subpath plugins such as `@deepseek-ai/dsh-tool-fs/read`, `@deepseek-ai/dsh-tool-fs/write`, and `@deepseek-ai/dsh-tool-fs/edit` for focused deployments. Root and subpath plugins follow the same rule: they inject `fs` and never import an implementation package. + +## `ctx.fs` contract + +`@deepseek-ai/dsh-fs` owns a semantic filesystem service. It is higher-level than `readFile` / `writeFile` so `tool-fs` does not reimplement path resolution, versioning, text decoding, binary rejection, pagination, atomic replacement, symlink behavior, or literal edit semantics. + +The exact TypeScript signatures are implementation details for the PR, but the interface must cover four semantic operations: + +- Resolve a model/plugin-supplied path into a backend-defined target. +- Read a bounded UTF-8 text page from a target. +- Create or replace a UTF-8 text file. +- Edit an existing UTF-8 text file by literal replacement. + +The interface must also cover file state: + +- Derive a file-state owner from the current execution context, normally the active agent session. +- Record that the owner saw a target at a backend-defined version. +- Determine whether that owner has a full editable view of a target. +- Use the recorded version as the stale guard for write/edit operations that require prior observation. +- Refresh the recorded state after a successful write/edit so follow-up modifications can proceed without forcing another read. + +The in-memory shape is conceptually a weakly-owned cache: file state is keyed first by the derived owner object, then by the backend `targetKey`. The owner is usually `exec.agent.session`, but `dsh-fs` treats it as opaque and does not import `dsh-session`. Each cached `FileState` records the `targetKey`, `displayPath`, backend `version`, current view (`full` or `partial`), update time, and source (`read`, `write`, `edit`, or a future seed path). Only a `full` view authorizes write/edit. A `partial` view records useful context (paged read, truncated read, injected context) but does not grant edit authority. + +Path resolution should be explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity. + +Resolved targets must expose at least three concepts: + +- The original input path, for diagnostics. +- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path. +- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend. + +Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. `ctx.fs` records versions in its file-state store for stale checks; consumers may display related metadata but must not interpret the version token. + +Text reads return structured UTF-8 line records or ranges with pagination metadata. `tool-fs` owns line-numbered model text rendering; the backend owns bounded line length, bounded output bytes, binary-file rejection, total-line accounting, and whether the returned content is a partial view of the file. + +When a read has a file-state owner, `ctx.fs` records the target, version, display path, view metadata, timestamp, and source. Partial views are useful context but do not authorize write/edit unless a future operation can prove the model saw the raw editable content. + +Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. For updates to existing files, `ctx.fs` should require a full prior file state for the current owner and reject absent or partial state. The backend then compares the current file version to the recorded version and rejects stale writes. If the recorded target no longer exists, the write is stale rather than a create. A create is expressed as a write to a target with no existing file and does not require prior state or a file-state owner. + +Literal edit is part of `ctx.fs`, not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, prior-file-state checking, stale-version checking, and atomic read-modify-write are filesystem/backend semantics. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition. + +Direct tool executions without a derivable file-state owner can still exercise lower-level helpers in tests. Production `write`/`edit` tool calls should reject without an owner when they update an existing target, because those operations require prior state. Owner-less `write` may still create a new file when the backend confirms that the target does not already exist. + +Filesystem contract failures are thrown as `FsError extends HarnessError` in the first implementation, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. Initial codes should include `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, and `FS_EDIT_NOT_FOUND`. + +## Tool consumer behavior + +`@deepseek-ai/dsh-tool-fs` is the model-facing consumer. It owns tool names, JSON schemas, argument validation at the model boundary, prompt sections, and result formatting. It does not own filesystem execution. + +The first tool suite contains: + +- `read`: inspect a UTF-8 text file and return line-numbered content with pagination guidance. +- `write`: create or fully replace a UTF-8 text file. +- `edit`: update an existing UTF-8 text file by replacing literal text, requiring a unique match by default and allowing an explicit replace-all mode. + +Each tool follows the same execution shape: + +1. Validate and normalize model arguments. +2. Call the appropriate `ctx.fs` operation. +3. Format the result as `ContentBlock[]` for the model. +4. Let thrown backend/tool errors flow through `ToolRegistry.execute()`, which converts them into `isError` tool results. + +The package registers prompt guidance through `ctx.systemPrompt.section(...)` and registers schemas through `ctx.tools.register(...)`. Tool schemas still flow into the normal prompt assembly path via `SystemPrompt.assemble()` and `ToolRegistry.schemas()`; no agent-loop changes are required. + +The tool package must keep model-facing contracts stable when backends change. A local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas should not change solely because the backend changes. + +The first implementation requires a prior full `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran or by reading the file-state cache. It passes the current execution context to `ctx.fs`, and `ctx.fs` derives the file-state owner and enforces file-state/stale-version policy. Creating a new file with `write` does not require prior state or an owner. + +The root plugin registers the full suite by composing the per-tool registration helpers. The subpath plugins register one tool each for focused deployments and tests. Both forms inject `fs`, `tools`, and `systemPrompt`. + +## Migration plan + +This RFC starts from `origin/master`, where no filesystem tool package exists yet. The final implementation should add the new three-package topology directly: + +1. Add `packages/fs/fs` with the `ctx.fs` abstract service and vocabulary types. +2. Add `packages/fs/fs-local` with the local backend implementation and backend-level tests. +3. Add `packages/fs/tool-fs` with the model-facing `read`, `write`, and `edit` tools over `ctx.fs`. +4. Wire examples by loading a `ctx.fs` provider first (`dsh-fs-local`), then the consumer (`dsh-tool-fs` or one of its subpath plugins). +5. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. + +This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so root and subpath `tool-fs` plugins share the same read-before-write/edit policy automatically. + +If this work is split into multiple PRs, they should follow the seam order: + +1. Interface PR: `dsh-fs` only, with service registration and contract tests. +2. Implementation PR: `dsh-fs-local`, with real filesystem behavior tests. +3. Consumer PR: `dsh-tool-fs`, examples, docs, and integration tests. + +The earlier combined package name `@deepseek-ai/dsh-fs-tools` should not become part of the new public surface. + +## Tests + +Tests should follow the package boundary, not only the user-visible tools. + +`dsh-fs` tests cover the service seam itself: a provider registers `ctx.fs`, duplicate providers follow Cordis service behavior, disposal removes the service, and any shared contract helpers or type-level utilities behave as documented. + +`dsh-fs-local` tests cover real filesystem behavior through the `ctx.fs` interface, not through model tools. They should include path resolution, absolute paths, `..` segments, symlinks inside and outside the configured base directory, reading small and large text files, pagination, output caps, binary-file rejection, abort handling, full-file create/update writes, owner-less creates, owner-less update rejection, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, file-state recording after reads, session/owner isolation, read-before-update rejection, stale-version rejection, partial-view rejection, structured `FsError` codes, and file-state refresh after successful writes/edits. + +Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive-pattern classes this repo has been bitten by: + +- **Atomic-write temp-file safety**, not just cleanup. The atomic replace must write its temp file into a private (`0700`) directory, with a random name and an exclusive owner-only (`'wx'`, `0o600`) open, mirroring the bash spill-file rules — predictable world-readable temp paths invite symlink races and disclosure. Assert the temp file's permissions and that a pre-existing temp path does not get clobbered, alongside the existing cleanup-on-failure path. +- **Implementation requirement:** `dsh-fs-local` write/edit use the same private-temp primitive: a random `0700` staging directory next to the target, an exclusive `0o600` temp file, cleanup on failure, and a final atomic rename. Do not move this RFC to `implemented/` if that primitive regresses or is deliberately revised. +- **`targetKey` identity through symlinks.** Two different input paths that resolve to the same realpath must share one file-state entry: a `read` via path A must satisfy the read-before-edit guard for an `edit` via symlink path B, and a stale write through one path must be detected through the other. This is the contract that makes the stale guard correct, so test it directly. +- **Concurrency / stale races.** The RFC names edit as race-prone (see Risks). Test that two concurrent write/edit operations against the same target settle deterministically: one succeeds and the other is rejected with `FS_STALE_VERSION` rather than silently overwriting, and that a successful edit refreshes recorded state so an immediately-following edit by the same owner proceeds. +- **HMR safety and disposal.** `dsh-fs-local` registers `ctx.fs` and owns the in-memory file-state store, so it needs its own HMR-safety test (register the backend on a fiber, dispose it, assert the `ctx.fs` provider is withdrawn and the file-state store is released — a later provider starts with no inherited state). + +`dsh-tool-fs` tests cover the consumer surface with a fake `ctx.fs` implementation. They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, subpath plugin registration, and HMR cleanup. + +Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout. + +Repo gates for the implementation include the focused vitest suites, `yarn typecheck`, `yarn test:coverage` for runtime code, and build/publint coverage after adding package entrypoints. + +## Risks + +**`cwd` can be mistaken for a sandbox.** The local backend's base directory is a resolution default, not automatically a containment boundary. If containment is required, it must be enforced by the backend contract or by a permission/sandbox plugin on `tools/execute`. + +**The interface can become too local.** Returning fields such as `absolutePath` from `ctx.fs` would make remote, sandboxed, or virtual backends awkward. The contract should expose display metadata without requiring consumers to understand host paths. + +**The interface can become too thin.** If `ctx.fs` only mirrors `node:fs` primitives, `tool-fs` will reimplement binary detection, pagination, atomic writes, and edit semantics. That recreates the coupling this RFC is trying to avoid. + +**Edit semantics are race-prone.** Literal edit is a read-modify-write operation. Without a stale-content guard or backend-level atomic edit primitive, concurrent edits can overwrite each other. The first implementation should document its guarantees clearly; stronger compare-and-swap semantics can be added later if needed. + +**File state inside `ctx.fs` can blur concerns.** Recording what an execution context has seen is workflow state, not raw filesystem I/O. This RFC still keeps it inside the filesystem seam because write/edit safety depends on backend-defined target identity and version tokens, and because putting it in `tool-fs` would couple write/edit to the read tool implementation. The boundary is narrow: `ctx.fs` derives the file-state owner, records file state, and checks stale versions, while `tool-fs` owns only model-facing schemas and formatting. + +**The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract. + +**File-state persistence is deferred.** The first implementation can keep file state in memory. Resumed sessions should conservatively require files to be read again before write/edit tools accept updates until a future session-event or persistence mechanism makes file state replayable. + +**Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and should stay limited to the error vocabulary. + +**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package surface after shipping model-facing tools would be more expensive. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md new file mode 100644 index 0000000000..45928e9871 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -0,0 +1,113 @@ +# RFC: Filesystem tool schemas — model-facing read/write/edit shapes + +Status: implemented + +## Problem + +[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the three-package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`), and the observed-file/stale-version policy for read-before-write/edit checks. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`. + +The schema should be small enough to implement in the first `dsh-tool-fs` pass, but stable enough that future local/remote/sandboxed filesystem backends do not require model-facing churn. It should also avoid importing every option from reference systems. Claude Code and OpenCode expose similar core file tools but differ in naming style and extra flags; this RFC chooses the minimal shared surface for the prototype. + +## Proposal + +`@deepseek-ai/dsh-tool-fs` exposes these three model-facing tools in the first filesystem suite: + +| Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype | +|---|---|---|---|---|---| +| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES | +| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Updates to existing files require prior observation through `ctx.fs`; new-file creates do not. | YES | +| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; requires prior full observation through `ctx.fs`. | YES | + +The schema uses snake_case field names (`file_path`, `old_string`, `new_string`, `replace_all`) to align with Claude Code and with existing DeepSeek Harness tool-schema examples. The consumer package translates these model-facing names into internal `ctx.fs` requests. + +## Tool schemas + +### `read` + +`read` inspects a UTF-8 text file and returns line-numbered content. + +Arguments: + +- `file_path: string` — required. Path to read, resolved by `ctx.fs`. +- `offset?: number` — optional. 1-based first line to return. Defaults to the first line. +- `limit?: number` — optional. Maximum number of lines to return. Defaults and caps are implementation details of `dsh-tool-fs` / `ctx.fs`. + +Non-goals for the first pass: + +- No PDF `pages` argument. +- No image or multimodal file reads. +- No directory listing through `read`; if needed, listing becomes a separate future tool. + +### `write` + +`write` creates or fully replaces a UTF-8 text file. + +Arguments: + +- `file_path: string` — required. Path to write, resolved by `ctx.fs`. +- `content: string` — required. Full UTF-8 text content to write. + +For existing files, `write` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. Creating a new file does not require prior state or an owner. + +The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by `ctx.fs` file state and backend-produced versions, not by asking the model to copy version tokens through the schema. + +### `edit` + +`edit` updates an existing UTF-8 text file by replacing literal text. + +Arguments: + +- `file_path: string` — required. Path to edit, resolved by `ctx.fs`. +- `old_string: string` — required. Literal text to replace. Empty strings are invalid in the first pass. +- `new_string: string` — required. Literal replacement text; an empty string deletes the match. +- `replace_all?: boolean` — optional. Defaults to false. When false, `old_string` must identify exactly one match. + +`edit` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. + +The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It uses one strict literal replacement mode so the model-facing contract stays simple and the backend can own exact-match, duplicate-match, line-ending, and stale-version semantics. + +## Result shape + +The first implementation returns `ContentBlock[]` through the existing `ToolDefinition.execute()` contract. `ctx.fs` returns structured filesystem results and owns file-state recording/refreshing; `tool-fs` formats those results into the model projection. + +Default native projections: + +| Tool | Structured `ctx.fs` outcome consumed by `tool-fs` | Default model projection | +|---|---|---| +| `read` | returned lines, returned line count, total line count, target display path, file version, partial-view flag | line-numbered text plus pagination footer | +| `write` | create/update operation, target display path, new file version | concise create/update success text | +| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text | + +The structured outcome should not restate model arguments such as `file_path`, `old_string`, or `content` unless the backend has resolved them into new information such as `displayPath`, `targetKey`, or a new version. Token-conscious truncation is part of the model projection, not the backend's canonical result. + +## Deferred + +The following are deliberately out of scope for the first filesystem schema pass: + +- Model-facing `expected_hash`, `expected_version`, or `create_only` parameters. +- Directory listing, glob, grep, and search tools. +- Binary-safe read/write operations. +- PDF/image/multimodal `read`. +- Code Mode projection values for filesystem tools. +- A canonical edit diff format. + +## Tests + +`dsh-tool-fs` schema tests should assert: + +- `read` requires `file_path` and accepts optional positive integer `offset` / `limit`. +- `write` requires `file_path` and `content`. +- `edit` requires `file_path`, `old_string`, and `new_string`, accepts optional boolean `replace_all`, rejects empty `old_string`, and defaults `replace_all` to false. +- The registered JSON schemas use the snake_case field names in this RFC. +- The tool descriptions accurately describe that existing-file `write` and `edit` require a prior full read in the same execution context, while new-file `write` does not. +- The root plugin and subpath plugins register the same schemas. + +Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` with a fake or local `ctx.fs` provider and verify that model arguments are translated into the expected `ctx.fs` calls. + +## Risks + +**The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the first implementation focused, but users may ask for those quickly. They should be added as separate RFCs or focused follow-ups rather than overloaded into the initial schema. + +**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and `ctx.fs` observed-file state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. + +**Naming becomes public surface.** Once shipped, changing `file_path` to `filePath` or `old_string` to `oldString` would churn prompts, examples, and downstream clients. This RFC chooses snake_case up front and treats it as the stable model-facing contract. diff --git a/packages/README.md b/packages/README.md index f39d7b70a1..dd418f97ca 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | +| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | @@ -30,6 +31,9 @@ dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) +dsh-fs ← dsh-llm (abstract filesystem seam) +dsh-fs-local ← dsh-fs (FileSystem impl) +dsh-tool-fs ← dsh-fs, dsh-tools (file tool schemas) dsh-llm-deepseek ← dsh-llm (DeepSeek adapter) dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter) dsh-agent-loop ← dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent @@ -58,6 +62,9 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` | | `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | +| `fs/` | `fs` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | | `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | diff --git a/packages/fs/README.md b/packages/fs/README.md new file mode 100644 index 0000000000..15757698b2 --- /dev/null +++ b/packages/fs/README.md @@ -0,0 +1,11 @@ +# fs/ - filesystem capability family + +The filesystem capability seam: an abstract filesystem interface, a local implementation, and the model-facing file tools. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `fs/` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `tool-fs/` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | + +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the interface or model-facing tool schemas. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md new file mode 100644 index 0000000000..52dfa2e38b --- /dev/null +++ b/packages/fs/fs-local/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-fs-local + +The **local-filesystem implementation** of the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the four `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. + +```ts ignore-check +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' + +await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) +// ctx.fs is now the local backend; load @deepseek-ai/dsh-tool-fs to expose read/write/edit to the model. +``` + +## Behavior + +- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path keeps its absolute path as the key so creates still get a stable identity. `displayPath` is the absolute (un-resolved) path. +- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line. The `version` is `mtimeMs:size`. +- **`createOrReplace`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. Honors the `FsExpectation`: an `observed` write must match the recorded version (else `FS_STALE_VERSION`); a `partial` write onto an existing file is rejected (`FS_PARTIAL_OBSERVATION`); an `unobserved` write onto an existing file is rejected (`FS_NOT_OBSERVED`). +- **`applyEdit`** — atomic literal read-modify-write over the same primitive. Verifies the expected version, LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). + +## `cwd` is not a sandbox + +`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Risks section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#risks). + +The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json new file mode 100644 index 0000000000..8c5591398e --- /dev/null +++ b/packages/fs/fs-local/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-fs-local", + "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts new file mode 100644 index 0000000000..8c94abee24 --- /dev/null +++ b/packages/fs/fs-local/src/fsio.ts @@ -0,0 +1,470 @@ +/** + * Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept + * separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so + * the raw read/write/edit mechanics can be unit-tested without a Context. + * + * The reader uses two code paths so a single huge line can never balloon + * memory: a **fast path** (`readFile` + in-memory split) for files under + * {@link FAST_PATH_MAX_SIZE}, and a **streaming path** (manual newline scan + * with a capped line buffer) for larger files. Both reject NUL-byte binary + * samples and keep only the requested page in memory. + * + * Writes are atomic: content goes to a temp file opened exclusively (`wx`, + * `0o600`, so a pre-existing path can never be clobbered and write-in-progress + * bytes stay owner-only) inside a randomly-named private staging directory + * (`0o700`) next to the target, then `rename`d over the target. Edits are + * read-modify-write over the same atomic primitive. + * + * @module @deepseek-ai/dsh-fs-local/fsio + */ + +import { randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises' +import type { Stats } from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsReadRequest, FsTextLine, FsView } from '@deepseek-ai/dsh-fs' + +/** Default and maximum number of lines returned by one read. */ +export const READ_LIMIT = 2000 + +/** Maximum characters returned for a single line. */ +export const READ_MAX_LINE_LENGTH = 2000 + +/** Maximum bytes returned for selected file lines. */ +export const READ_MAX_BYTES = 50 * 1024 + +/** Files smaller than this use the in-memory fast path; larger files stream. */ +export const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024 + +const READ_MAX_BYTES_LABEL = `${READ_MAX_BYTES / 1024} KB` +const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` +const BINARY_SAMPLE_BYTES = 8192 +const NUL_CHAR = String.fromCharCode(0) +const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 + +/** + * Test seam: lets specs force the streaming path (via a small + * `fastPathMaxSize`) and pin the temp-file name (to prove exclusive-open + * behavior) without a 10 MB fixture or a name race. + */ +export interface FsIoInternals { + /** Override {@link FAST_PATH_MAX_SIZE} for routing. */ + fastPathMaxSize?: number + /** Override the generated private staging-dir name (relative to the target dir). */ + tempDirName?: (writePath: string) => string + /** Override the generated temp-file name (relative to the private staging dir). */ + tempName?: (writePath: string) => string + /** Test hook after the temp file is written/synced but before final chmod+rename. */ + inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise +} + +/** A resolved local path: the absolute path shown to callers and its realpath identity. */ +export interface LocalTarget { + /** Absolute path (symlinks not resolved) — used for display. */ + displayPath: string + /** Realpath identity — used as the stable target key and the I/O path. */ + targetKey: string +} + +/** Result of probing a path: null when it does not exist. */ +export interface PathInfo { + version: string + mode: number + isFile: boolean +} + +function isENOENT(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */ +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} +/* v8 ignore stop */ + +function throwIfAborted(signal: AbortSignal | undefined, verb: string): void { + if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED') +} + +/** Opaque version token from a stat: mtime (ns precision) + size. */ +function versionOf(info: Stats): string { + return `${info.mtimeMs}:${info.size}` +} + +/** + * Resolve a path to its absolute display path and realpath identity. Relative + * paths are based on `cwd`. The `targetKey` realpaths the parent directory and + * re-appends the basename, so a not-yet-created file gets the same stable key + * it will have after creation (the directory exists even when the file does + * not). Two input paths reaching the same file via symlinks share one key. + * Falls back to the absolute path when even the parent cannot be resolved. + */ +export async function resolveLocalTarget(cwd: string, path: string): Promise { + if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND') + const displayPath = resolve(cwd, path) + try { + // Prefer the file's own realpath (resolves a symlinked file to its target). + return { displayPath, targetKey: await realpath(displayPath) } + } catch (error: unknown) { + /* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to parent-dir resolution. */ + if (!isENOENT(error)) throw error + } + try { + // File absent: realpath the parent dir + basename so creates get a stable key. + return { displayPath, targetKey: join(await realpath(dirname(displayPath)), basename(displayPath)) } + } catch (error: unknown) { + /* v8 ignore next -- parent-dir realpath failing needs the dir itself to be missing/unreadable; fall back to the absolute path. */ + if (!isENOENT(error)) throw error + return { displayPath, targetKey: displayPath } + } +} + +/** Probe a path for its version, mode, and regular-file status. Null if absent. */ +export async function probe(absolutePath: string): Promise { + try { + const info = await stat(absolutePath) + return { version: versionOf(info), mode: info.mode & 0o777, isFile: info.isFile() } + } catch (error: unknown) { + /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */ + if (!isENOENT(error)) throw error + return null + } +} + +// --- Reading --- + +interface PageAccumulator { + lines: FsTextLine[] + totalLines: number + outputBytes: number + truncatedByBytes: boolean + done: boolean +} + +function newAccumulator(): PageAccumulator { + return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } +} + +function truncateReadLine(line: string): string { + return line.length > READ_MAX_LINE_LENGTH + ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` + : line +} + +function lineByteSize(line: string, currentLineCount: number): number { + return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0) +} + +function consumeLine(acc: PageAccumulator, rawLine: string, request: FsReadRequest): void { + acc.totalLines += 1 + if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return + + const text = truncateReadLine(rawLine) + const bytes = lineByteSize(text, acc.lines.length) + if (acc.outputBytes + bytes > READ_MAX_BYTES) { + acc.truncatedByBytes = true + acc.done = true + return + } + acc.outputBytes += bytes + acc.lines.push({ number: acc.totalLines, text }) +} + +function stripCarriageReturn(line: string): string { + return line.endsWith('\r') ? line.slice(0, -1) : line +} + +/** The outcome shape `readTextPage` returns (minus the offset/limit echo, which the caller adds). */ +export interface ReadPageResult { + lines: FsTextLine[] + totalLines: number + truncatedByBytes: boolean + view: FsView + version: string +} + +function buildResult(acc: PageAccumulator, request: FsReadRequest, version: string, displayPath: string): ReadPageResult { + if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) { + throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND') + } + const endLine = acc.lines.at(-1)?.number ?? Math.max(0, request.offset - 1) + const view: FsView = request.offset === 1 && !acc.truncatedByBytes && endLine >= acc.totalLines ? 'full' : 'partial' + return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes, view, version } +} + +/** + * Read a bounded UTF-8 text-file page. Rejects non-regular files and NUL-byte + * binary samples; dispatches to the fast or streaming path by file size. + */ +export async function readTextPage( + target: LocalTarget, + request: FsReadRequest, + signal?: AbortSignal, + internals: FsIoInternals = {}, +): Promise { + throwIfAborted(signal, 'read') + const absolutePath = target.targetKey + let info: Stats + try { + info = await stat(absolutePath) + } catch (error: unknown) { + /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */ + if (!isENOENT(error)) throw error + throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } + if (!info.isFile()) throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + + const version = versionOf(info) + const fastPathMax = internals.fastPathMaxSize ?? FAST_PATH_MAX_SIZE + return info.size < fastPathMax + ? readTextPageFast(target, request, version, signal) + : readTextPageStreaming(target, request, version, signal) +} + +async function readTextPageFast( + target: LocalTarget, + request: FsReadRequest, + version: string, + signal?: AbortSignal, +): Promise { + const raw = await readFile(target.targetKey, signal ? { signal } : {}) + throwIfAborted(signal, 'read') + if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) { + throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') + } + + const text = raw.toString('utf8') + const acc = newAccumulator() + let startPos = 0 + let newlinePos: number + while ((newlinePos = text.indexOf('\n', startPos)) !== -1) { + consumeLine(acc, stripCarriageReturn(text.slice(startPos, newlinePos)), request) + if (acc.done) break + startPos = newlinePos + 1 + } + if (!acc.done && startPos < text.length) { + consumeLine(acc, stripCarriageReturn(text.slice(startPos)), request) + } + return buildResult(acc, request, version, target.displayPath) +} + +async function readTextPageStreaming( + target: LocalTarget, + request: FsReadRequest, + version: string, + signal?: AbortSignal, +): Promise { + const stream = createReadStream(target.targetKey, { encoding: 'utf8', ...signal ? { signal } : {} }) + const acc = newAccumulator() + let lineBuffer = '' + let firstChunk = true + + function appendToLineBuffer(segment: string): void { + if (lineBuffer.length >= LINE_BUFFER_CAP) return + lineBuffer += segment + if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP) + } + + function flushLine(): void { + consumeLine(acc, stripCarriageReturn(lineBuffer), request) + lineBuffer = '' + } + + try { + for await (const chunk of stream as AsyncIterable) { + if (firstChunk) { + firstChunk = false + if (chunk.slice(0, BINARY_SAMPLE_BYTES).includes(NUL_CHAR)) { + throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') + } + } + let startPos = 0 + let newlinePos: number + while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) { + appendToLineBuffer(chunk.slice(startPos, newlinePos)) + flushLine() + startPos = newlinePos + 1 + if (acc.done) return buildResult(acc, request, version, target.displayPath) + } + appendToLineBuffer(chunk.slice(startPos)) + } + } catch (error: unknown) { + /* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */ + if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') + throw error + } + + if (lineBuffer.length > 0) flushLine() + return buildResult(acc, request, version, target.displayPath) +} + +/** Format the line-numbered body + pagination footer for a read page. */ +export function formatReadBody(result: ReadPageResult, offset: number): string { + const endLine = result.lines.at(-1)?.number ?? Math.max(0, offset - 1) + let footer: string + if (result.truncatedByBytes) { + footer = `(Output capped at ${READ_MAX_BYTES_LABEL}. Showing lines ${offset}-${endLine}. Use offset=${endLine + 1} to continue.)` + } else if (endLine < result.totalLines) { + footer = `(Showing lines ${offset}-${endLine} of ${result.totalLines}. Use offset=${endLine + 1} to continue.)` + } else { + footer = `(End of file - total ${result.totalLines} lines)` + } + return result.lines.length > 0 + ? `${result.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}` + : footer +} + +// --- Writing --- + +async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise { + try { + await rm(stagingDir, { recursive: true, force: true }) + } catch (cleanupError: unknown) { + /* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */ + throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError }) + } + throw originalError +} + +/** + * Atomically write `content` to `absolutePath`: create parent dirs, write to a + * randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private + * (`0o700`) staging directory, fsync, optionally chmod to the final mode while + * still private, then rename over the target. `mode` (when given) preserves an + * existing file's permissions across the replace. + */ +export async function writeFileAtomic( + absolutePath: string, + content: string, + mode: number | undefined, + signal: AbortSignal | undefined, + internals: FsIoInternals = {}, +): Promise { + throwIfAborted(signal, 'write') + const directory = dirname(absolutePath) + await mkdir(directory, { recursive: true }) + + throwIfAborted(signal, 'write') + const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir` + const stagingDir = join(directory, stagingDirName) + const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp` + const tempPath = join(stagingDir, tempName) + let handle: Awaited> | undefined + let stagingCreated = false + try { + await mkdir(stagingDir, { mode: 0o700 }) + stagingCreated = true + await chmod(stagingDir, 0o700) + + handle = await open(tempPath, 'wx', 0o600) + await handle.chmod(0o600) + await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} }) + await handle.sync() + await internals.inspectTemp?.({ stagingDir, tempPath }) + if (mode !== undefined) await handle.chmod(mode) + await handle.close() + handle = undefined + + throwIfAborted(signal, 'write') + await rename(tempPath, absolutePath) + await rm(stagingDir, { recursive: true, force: true }) + } catch (error: unknown) { + /* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */ + let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error + /* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */ + if (handle) { + try { + await handle.close() + } catch (closeError: unknown) { + failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure }) + } + } + if (!stagingCreated) throw failure + return removeStagingDirOrThrow(stagingDir, failure) + } +} + +// --- Editing --- + +/** Line ending style detected before LF normalization. */ +export type LineEndings = 'LF' | 'CRLF' + +function normalizeLineEndings(content: string): string { + return content.replaceAll('\r\n', '\n') +} + +function detectLineEndings(raw: string): LineEndings { + const sample = raw.slice(0, 4096) + const crlfCount = sample.split('\r\n').length - 1 + const lfCount = sample.split('\n').length - 1 - crlfCount + return crlfCount > lfCount ? 'CRLF' : 'LF' +} + +function restoreLineEndings(content: string, lineEndings: LineEndings): string { + return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n') +} + +function countOccurrences(content: string, needle: string): number { + let count = 0 + let index = 0 + while (true) { + const found = content.indexOf(needle, index) + if (found === -1) return count + count += 1 + index = found + needle.length + } +} + +/** + * Read and decode a file for editing: rejects binaries, returns LF-normalized + * content plus the original line-ending style for write-back. + */ +export async function readForEdit( + absolutePath: string, + displayPath: string, + signal?: AbortSignal, +): Promise<{ content: string; lineEndings: LineEndings }> { + throwIfAborted(signal, 'edit') + const buffer = await readFile(absolutePath, signal ? { signal } : {}) + throwIfAborted(signal, 'edit') + if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT') + const raw = buffer.toString('utf8') + return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) } +} + +/** + * Apply a literal replacement to LF-normalized content. Throws + * `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and + * `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns + * the edited content (still LF-normalized) and the replacement count. + */ +export function applyLiteralEdit( + content: string, + oldString: string, + newString: string, + replaceAll: boolean, + displayPath: string, +): { content: string; replacements: number } { + const oldNorm = normalizeLineEndings(oldString) + if (oldNorm.length === 0) { + throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND') + } + const newNorm = normalizeLineEndings(newString) + const replacements = countOccurrences(content, oldNorm) + if (replacements === 0) { + throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND') + } + if (!replaceAll && replacements > 1) { + throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, 'FS_AMBIGUOUS_EDIT') + } + return { content: content.split(oldNorm).join(newNorm), replacements } +} + +export { restoreLineEndings } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts new file mode 100644 index 0000000000..0184a8a323 --- /dev/null +++ b/packages/fs/fs-local/src/index.ts @@ -0,0 +1,197 @@ +/** + * Local-filesystem implementation of the `ctx.fs` seam. {@link LocalFileSystem} + * subclasses {@link FileSystem} and backs the four primitives with the host + * filesystem via {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution + * uses `realpath`, so the stable `targetKey` is the real file identity (two + * input paths reaching the same file through symlinks share one key, and writes + * land on the link target — preserving the link). + * + * Future sandboxed/remote/virtual backends are sibling packages implementing + * the same interface; loading this one populates `ctx.fs`. + * + * @module @deepseek-ai/dsh-fs-local + */ + +import { Context } from 'cordis' +import z from 'schemastery' +import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsVersion, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import { + applyLiteralEdit, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from './fsio.ts' +import type { FsIoInternals } from './fsio.ts' + +export { + FAST_PATH_MAX_SIZE, + READ_LIMIT, + READ_MAX_BYTES, + READ_MAX_LINE_LENGTH, + applyLiteralEdit, + formatReadBody, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalTarget, PathInfo, ReadPageResult } from './fsio.ts' + +/** Configuration for the local filesystem backend. */ +export interface Config { + /** Base directory for relative paths. Defaults to `process.cwd()`. */ + cwd?: string +} + +type ResolvedConfig = Required + +/** + * The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd} + * (a resolution default, NOT a containment boundary — see the filesystem + * capability-seam RFC); enforce + * containment with a stricter backend or a `tools/execute` permission plugin. + */ +export class LocalFileSystem extends FileSystem { + static Config: z = z.object({ + cwd: z.string().default(process.cwd()), + }) + + readonly config: ResolvedConfig + /** Test seam forwarded to fsio (force streaming path, pin temp names). */ + internals: FsIoInternals = {} + /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write + * window can't interleave, making concurrent writes/edits deterministically + * ordered (one wins, the rest see the new version and reject as stale). */ + private locks = new Map>() + + constructor(ctx: Context, config: Config) { + super(ctx) + this.config = config as ResolvedConfig + } + + /** Run `op` with exclusive access to `targetKey` (FIFO per key). */ + private async withLock(targetKey: string, op: () => Promise): Promise { + const prior = this.locks.get(targetKey) ?? Promise.resolve() + const run = prior.then(op, op) + // Keep the chain alive but swallow this op's result/throw for the *next* waiter. + const tail = run.then(() => undefined, () => undefined) + this.locks.set(targetKey, tail) + try { + return await run + } finally { + if (this.locks.get(targetKey) === tail) { + this.locks.delete(targetKey) + } + } + } + + override async resolve(path: string): Promise { + const local = await resolveLocalTarget(this.config.cwd, path) + return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } + } + + override async readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise { + const result = await readTextPage( + { displayPath: target.displayPath, targetKey: target.targetKey }, + request, + signal, + this.internals, + ) + return { + offset: request.offset, + limit: request.limit, + lines: result.lines, + totalLines: result.totalLines, + version: result.version, + view: result.view, + ...result.truncatedByBytes ? { truncatedByBytes: true } : {}, + } + } + + override async createOrReplace( + target: FsTarget, + content: string, + expected: FsExpectation, + signal?: AbortSignal, + ): Promise { + return this.withLock(target.targetKey, async () => { + const existing = await probe(target.targetKey) + if (existing && !existing.isFile) { + throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + + if (expected.kind === 'observed') { + // Stale guard: the file must still be at the version the owner observed. + if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION') + if (existing.version !== expected.version) { + throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') + } + } else if (expected.kind === 'partial') { + if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION') + throw new FsError(`cannot overwrite existing "${target.displayPath}" after only a partial read`, 'FS_PARTIAL_OBSERVATION') + } else if (existing) { + // Unobserved write onto an existing file: a blind overwrite — require a read first. + throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED') + } + + await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) + const after = await probe(target.targetKey) + return { + operation: existing ? 'update' : 'create', + version: this.versionAfterWrite(after, target), + } + }) + } + + override async applyEdit( + target: FsTarget, + edit: FsEditRequest, + expected: { version: FsVersion }, + signal?: AbortSignal, + ): Promise { + return this.withLock(target.targetKey, async () => { + const existing = await probe(target.targetKey) + if (!existing) throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (!existing.isFile) throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + if (existing.version !== expected.version) { + throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') + } + + const original = await readForEdit(target.targetKey, target.displayPath, signal) + const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath) + const content = restoreLineEndings(edited.content, original.lineEndings) + await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals) + + const after = await probe(target.targetKey) + return { + replacements: edited.replacements, + replaceAll: edit.replaceAll, + version: this.versionAfterWrite(after, target), + } + }) + } + + /* v8 ignore next 5 -- the post-write probe finding the file absent requires a + * concurrent unlink between rename and stat; fall back to a sentinel version. */ + private versionAfterWrite(after: { version: string } | null, target: FsTarget): string { + if (after) return after.version + return `missing:${target.targetKey}` + } +} + +export default LocalFileSystem diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts new file mode 100644 index 0000000000..ae1605892a --- /dev/null +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -0,0 +1,268 @@ +/** + * Tests for the local backend through the `ctx.fs` service: the full + * read→write→edit lifecycle with the read-before-write policy, stale-version + * guards, concurrency races, symlink identity, and HMR/disposal. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import type { FsExecContext } from '@deepseek-ai/dsh-fs' + +let dir: string +let ctx: Context +let fs: LocalFileSystem +let fiber: Awaited> + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-fs-')) + ctx = new Context() + fiber = await ctx.plugin(LocalFileSystem, { cwd: dir }) + fs = ctx.fs as LocalFileSystem +}) +afterEach(async () => { + await fiber.dispose() + await rm(dir, { recursive: true, force: true }) +}) + +const READ_ALL = { offset: 1, limit: 2000 } +const exec = (): FsExecContext => ({ agent: { session: {} } }) +function lockCount(localFs: LocalFileSystem): number { + return (localFs as unknown as { locks: Map> }).locks.size +} + +describe('registration', () => { + it('registers LocalFileSystem as ctx.fs with a default cwd', async () => { + const bare = new Context() + const bareFiber = await bare.plugin(LocalFileSystem) + expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd()) + await bareFiber.dispose() + }) +}) + +describe('read → write → edit lifecycle', () => { + it('creates a new file without a prior read', async () => { + const target = await fs.resolve('new.txt') + const outcome = await fs.write(target, 'fresh', exec()) + expect(outcome.operation).toBe('create') + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh') + }) + + it('updates an existing file after reading it', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + const outcome = await fs.write(target, 'new', owner) + expect(outcome.operation).toBe('update') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new') + }) + + it('edits an existing file after reading it', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + const outcome = await fs.edit(target, { oldString: 'world', newString: 'there', replaceAll: false }, owner) + expect(outcome.replacements).toBe(1) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('rejects an empty edit oldString through ctx.fs without hanging or changing the file', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + + await expect(fs.edit(target, { oldString: '', newString: 'boom', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') + }) + + it('propagates truncatedByBytes from a byte-capped read', async () => { + await writeFile(join(dir, 'big.txt'), Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const outcome = await fs.read(await fs.resolve('big.txt'), READ_ALL, exec()) + expect(outcome.truncatedByBytes).toBe(true) + expect(outcome.view).toBe('partial') + }) + + it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => { + await writeFile(join(dir, 'a.txt'), 'a b') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + await fs.edit(target, { oldString: 'a', newString: 'X', replaceAll: false }, owner) + await fs.edit(target, { oldString: 'b', newString: 'Y', replaceAll: false }, owner) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('X Y') + }) + + it('releases per-target mutation locks after success and failure', async () => { + const target = await fs.resolve('a.txt') + await fs.write(target, 'created', exec()) + expect(lockCount(fs)).toBe(0) + + await expect(fs.write(target, 'blind overwrite', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(lockCount(fs)).toBe(0) + }) +}) + +describe('read-before-write policy', () => { + it('rejects a blind overwrite of an existing file (no prior read)', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + await expect(fs.write(target, 'new', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects a write after only a partial read', async () => { + await writeFile(join(dir, 'a.txt'), 'one\ntwo') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, owner) + await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) + + it('rejects a write after a partial read when the file was deleted, without recreating it', async () => { + const path = join(dir, 'a.txt') + await writeFile(path, 'one\ntwo') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, owner) + await unlink(path) + + await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects an edit with no prior read (FS_NOT_OBSERVED)', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec())) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +describe('stale-version guard + concurrency (defensive class B)', () => { + it('rejects a write when the file changed since it was read', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + // An out-of-band change after the read. + await writeFile(join(dir, 'a.txt'), 'changed-externally') + await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('rejects an observed write when the file was deleted after the read', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + await unlink(join(dir, 'a.txt')) // file vanishes; observed write must fail (not silently create) + await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('two concurrent edits: one wins, the other is rejected as stale', async () => { + await writeFile(join(dir, 'a.txt'), 'base') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + // Both edits captured the same recorded version; only one rename can match it. + const results = await Promise.allSettled([ + fs.edit(target, { oldString: 'base', newString: 'one', replaceAll: false }, owner), + fs.edit(target, { oldString: 'base', newString: 'two', replaceAll: false }, owner), + ]) + const fulfilled = results.filter(r => r.status === 'fulfilled') + const rejected = results.filter(r => r.status === 'rejected') + expect(fulfilled).toHaveLength(1) + expect(rejected).toHaveLength(1) + expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(lockCount(fs)).toBe(0) + }) +}) + +describe('symlink targetKey identity (defensive class F)', () => { + it('a read via the real path authorizes an edit via the symlink path', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const owner = exec() + await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) + // Edit through the link: same realpath → same targetKey → prior read counts. + const linkTarget = await fs.resolve('link.txt') + const outcome = await fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner) + expect(outcome.replacements).toBe(1) + expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved, target written + }) + + it('write through a symlink preserves the link and writes the real target', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const owner = exec() + const linkTarget = await fs.resolve('link.txt') + await fs.read(linkTarget, READ_ALL, owner) + await fs.write(linkTarget, 'replaced', owner) + expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('replaced') + }) + + it('a stale change is detected across both paths', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const owner = exec() + await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) + await writeFile(join(dir, 'real.txt'), 'changed') // out-of-band via real path + const linkTarget = await fs.resolve('link.txt') + await expect(fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) +}) + +describe('non-regular targets', () => { + it('rejects writing onto a directory', async () => { + const target = await fs.resolve('.') // the cwd dir + await expect(fs.write(target, 'x', exec())).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('applyEdit rejects a target that vanished after the read', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const owner = exec() + const target = await fs.resolve('a.txt') + const version = (await fs.read(target, READ_ALL, owner)).version + await unlink(join(dir, 'a.txt')) + await expect(fs.applyEdit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })) + .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('applyEdit rejects a non-regular target', async () => { + const target = await fs.resolve('.') + await expect(fs.applyEdit(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: 'v' })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) +}) + +describe('HMR / disposal (defensive class D)', () => { + it('disposing the fiber withdraws ctx.fs', async () => { + const local = new Context() + const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) + expect(local.fs).toBeDefined() + await fiber.dispose() + expect(local.fs).toBeUndefined() + }) + + it('a fresh provider does not inherit recorded file state', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const local = new Context() + const owner = exec() + const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) + await (local.fs as LocalFileSystem).read(await local.fs.resolve('a.txt'), READ_ALL, owner) + await fiber.dispose() + + await local.plugin(LocalFileSystem, { cwd: dir }) + const fs2 = local.fs as LocalFileSystem + const target = await fs2.resolve('a.txt') + // Same owner object, but state was released on disposal. + await expect(fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts new file mode 100644 index 0000000000..77b8d8ccba --- /dev/null +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -0,0 +1,364 @@ +/** + * Cordis-free tests for the raw local-filesystem I/O: path resolution, + * fast/streaming reads, pagination/caps, binary rejection, atomic-write temp + * safety, literal edit matching, and line-ending handling. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + applyLiteralEdit, + formatReadBody, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from '@deepseek-ai/dsh-fs-local' +import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' + +let dir: string +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-')) +}) +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +const READ_ALL = { offset: 1, limit: 2000 } +const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: path }) + +describe('resolveLocalTarget', () => { + it('resolves a relative path from cwd and realpaths it', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'hi') + const target = await resolveLocalTarget(dir, 'a.txt') + expect(target.displayPath).toBe(file) + expect(target.targetKey).toBe(await (await import('node:fs/promises')).realpath(file)) + }) + + it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => { + const { realpath } = await import('node:fs/promises') + const target = await resolveLocalTarget(dir, 'missing.txt') + expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt')) + }) + + it('two paths to the same file via a symlink share one targetKey', async () => { + const real = join(dir, 'real.txt') + await writeFile(real, 'hi') + const link = join(dir, 'link.txt') + await symlink(real, link) + const viaReal = await resolveLocalTarget(dir, 'real.txt') + const viaLink = await resolveLocalTarget(dir, 'link.txt') + expect(viaLink.targetKey).toBe(viaReal.targetKey) + expect(viaLink.displayPath).toBe(link) + }) + + it('falls back to the absolute path when even the parent dir is absent', async () => { + const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt') + expect(target.targetKey).toBe(join(dir, 'no-such-dir', 'child.txt')) + }) + + it('rejects a blank path', async () => { + await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) +}) + +describe('readTextPage', () => { + it('reads a small file with line numbers and full view', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines).toEqual([ + { number: 1, text: 'one' }, + { number: 2, text: 'two' }, + { number: 3, text: 'three' }, + ]) + expect(result.totalLines).toBe(3) + expect(result.view).toBe('full') + }) + + it('paginates with offset/limit and reports a partial view', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree\nfour') + const result = await readTextPage(localTarget(file), { offset: 2, limit: 2 }) + expect(result.lines.map(l => l.number)).toEqual([2, 3]) + expect(result.view).toBe('partial') + expect(formatReadBody(result, 2)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') + }) + + it('a whole-file read from offset 1 is a full view; offset>1 is partial', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + expect((await readTextPage(localTarget(file), { offset: 1, limit: 10 })).view).toBe('full') + expect((await readTextPage(localTarget(file), { offset: 2, limit: 10 })).view).toBe('partial') + }) + + it('truncates an over-long line', async () => { + const file = join(dir, 'long.txt') + await writeFile(file, 'x'.repeat(3000)) + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + }) + + it('caps output bytes and reports truncatedByBytes', async () => { + const file = join(dir, 'big.txt') + const lines = Array.from({ length: 2000 }, () => 'y'.repeat(100)) + await writeFile(file, lines.join('\n')) + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.truncatedByBytes).toBe(true) + expect(formatReadBody(result, 1)).toContain('Output capped at 50 KB') + }) + + it('strips CRLF so a Windows file reads like LF', async () => { + const file = join(dir, 'crlf.txt') + await writeFile(file, 'one\r\ntwo\r\n') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + + it('reads an empty file at offset 1', async () => { + const file = join(dir, 'empty.txt') + await writeFile(file, '') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines).toEqual([]) + expect(result.totalLines).toBe(0) + expect(formatReadBody(result, 1)).toBe('(End of file - total 0 lines)') + }) + + it('rejects an offset past EOF', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + await expect(readTextPage(localTarget(file), { offset: 9, limit: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('rejects a binary file (fast path)', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('rejects a missing file and a directory', async () => { + await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('honors a pre-aborted signal', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one') + await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('passes a live (non-aborted) signal through the fast path', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal) + expect(result.totalLines).toBe(2) + }) + + describe('streaming path (forced via a tiny fastPathMaxSize)', () => { + const stream = { fastPathMaxSize: 1 } + + it('reads and paginates large files the same way', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree') + const result = await readTextPage(localTarget(file), { offset: 2, limit: 1 }, undefined, stream) + expect(result.lines).toEqual([{ number: 2, text: 'two' }]) + expect(result.totalLines).toBe(3) + }) + + it('rejects a binary file on the streaming path', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('caps a newline-free giant line without unbounded buffering', async () => { + const file = join(dir, 'one-line.txt') + await writeFile(file, 'z'.repeat(5000)) + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + }) + + it('honors abort on the streaming path', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort(), stream)).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('caps output bytes mid-stream', async () => { + const file = join(dir, 'big.txt') + await writeFile(file, Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.truncatedByBytes).toBe(true) + }) + + it('flushes a final line with no trailing newline', async () => { + const file = join(dir, 'no-nl.txt') + await writeFile(file, 'one\ntwo') // no trailing \n + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + + it('handles a trailing newline (no dangling buffer at EOF)', async () => { + const file = join(dir, 'nl.txt') + await writeFile(file, 'one\ntwo\n') // trailing \n → empty buffer at end + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + expect(result.totalLines).toBe(2) + }) + + it('passes a live (non-aborted) signal through to the stream', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal, stream) + expect(result.totalLines).toBe(2) + }) + + it('scans across multiple stream chunks', async () => { + // A file well past the default 64 KB stream highWaterMark yields multiple chunks, + // exercising the non-first-chunk branch and the line-buffer cap across appends. + const file = join(dir, 'multi.txt') + const lines = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`) + await writeFile(file, lines.join('\n')) + const result = await readTextPage(localTarget(file), { offset: 1, limit: 3 }, undefined, stream) + expect(result.lines[0]?.text.startsWith('line 0:')).toBe(true) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + expect(result.totalLines).toBeGreaterThanOrEqual(3) + }) + }) +}) + +describe('writeFileAtomic — temp-file safety (defensive class A)', () => { + it('writes through a private staging dir and owner-only temp file', async () => { + const file = join(dir, 'a.txt') + let inspected = false + await writeFileAtomic(file, 'hello', 0o640, undefined, { + inspectTemp: async ({ stagingDir, tempPath }) => { + inspected = true + expect((await stat(stagingDir)).mode & 0o777).toBe(0o700) + expect((await stat(tempPath)).mode & 0o777).toBe(0o600) + }, + }) + expect(inspected).toBe(true) + expect(await readFile(file, 'utf8')).toBe('hello') + const info = await stat(file) + expect(info.mode & 0o777).toBe(0o640) + expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) + }) + + it('creates new files owner-only by default', async () => { + const file = join(dir, 'a.txt') + await writeFileAtomic(file, 'hello', undefined, undefined) + expect((await stat(file)).mode & 0o777).toBe(0o600) + }) + + it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => { + const file = join(dir, 'a.txt') + const tempDirName = '.fixed-temp.tmpdir' + await mkdir(join(dir, tempDirName)) + await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep') + await expect( + writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }), + ).rejects.toMatchObject({ code: 'EEXIST' }) + // The pre-existing staging dir is intact and the target was not created. + expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep') + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('creates parent directories as needed', async () => { + const file = join(dir, 'nested', 'deep', 'a.txt') + await writeFileAtomic(file, 'hi', undefined, undefined) + expect(await readFile(file, 'utf8')).toBe('hi') + }) + + it('passes a live (non-aborted) signal through the write', async () => { + const file = join(dir, 'a.txt') + await writeFileAtomic(file, 'hi', undefined, new AbortController().signal) + expect(await readFile(file, 'utf8')).toBe('hi') + }) + + it('aborts before writing when the signal is already aborted', async () => { + const file = join(dir, 'a.txt') + await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('cleans up the temp file when the final rename fails', async () => { + const sub = join(dir, 'occupied') + await mkdir(sub) // rename(temp, sub) fails because sub is a non-empty/dir target + await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error) + // No leftover staging dirs in the directory. + expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) + }) +}) + +describe('applyLiteralEdit', () => { + it('replaces a unique match', () => { + expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 }) + }) + + it('rejects zero matches', () => { + expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' })) + }) + + it('rejects an empty oldString without scanning forever', () => { + expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' })) + }) + + it('rejects multiple matches without replaceAll', () => { + expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' })) + }) + + it('replaces all matches with replaceAll', () => { + expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 }) + }) + + it('matches across normalized line endings', () => { + expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1) + }) +}) + +describe('readForEdit + restoreLineEndings', () => { + it('round-trips CRLF: matches on LF, writes back CRLF', async () => { + const file = join(dir, 'crlf.txt') + await writeFile(file, 'one\r\ntwo\r\n') + const original = await readForEdit(file, file) + expect(original.lineEndings).toBe('CRLF') + const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file) + expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n') + }) + + it('rejects a binary file', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x00, 0x01])) + await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('passes a live (non-aborted) signal through the read', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const original = await readForEdit(file, file, new AbortController().signal) + expect(original.content).toBe('one\ntwo') + }) +}) + +describe('probe', () => { + it('returns null for a missing path and info for a file', async () => { + expect(await probe(join(dir, 'nope'))).toBeNull() + const file = join(dir, 'a.txt') + await writeFile(file, 'hi') + const info = await probe(file) + expect(info?.isFile).toBe(true) + expect(typeof info?.version).toBe('string') + }) + + it('marks a directory as not a regular file', async () => { + const sub = join(dir, 'sub') + await mkdir(sub) + expect((await probe(sub))?.isFile).toBe(false) + }) +}) diff --git a/packages/fs/fs-local/tsconfig.json b/packages/fs/fs-local/tsconfig.json new file mode 100644 index 0000000000..895a46ef55 --- /dev/null +++ b/packages/fs/fs-local/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md new file mode 100644 index 0000000000..856ec95076 --- /dev/null +++ b/packages/fs/fs/README.md @@ -0,0 +1,38 @@ +# @deepseek-ai/dsh-fs + +The **filesystem seam**: an abstract `FileSystem` service (`ctx.fs`) defining WHAT a filesystem backend does — resolve paths, read bounded text pages, create/replace files, apply literal edits — without saying HOW. + +This package is one third of the filesystem capability, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) and [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md)): + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-fs` (this) | the interface: abstract service + vocabulary types + read-before-write/edit policy | +| `@deepseek-ai/dsh-fs-local` | an implementation: the host filesystem | +| `@deepseek-ai/dsh-tool-fs` | the model-facing `read`/`write`/`edit` tool schemas over `ctx.fs` | + +A future sandboxed, virtual, or remote backend implements this interface and the tool schemas don't change. + +## Service API (`ctx.fs`) + +Consumers call the concrete public API; backends implement the four primitives. + +| Member | Kind | Semantics | +|---|---|---| +| `resolve(path)` | primitive | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | +| `readPage(target, request, signal?)` | primitive | Read a bounded UTF-8 text page. Returns line-numbered content, `totalLines`, an opaque `version`, and a `view` (`full` only when the page covered the whole file). | +| `createOrReplace(target, content, expected, signal?)` | primitive | Create/replace a file honoring the `FsExpectation` stale guard. | +| `applyEdit(target, edit, expected, signal?)` | primitive | Atomic literal read-modify-write, verifying the expected version. `oldString` must be non-empty. | +| `read(target, request, exec?, signal?)` | public | Calls `readPage`, then records observed state for the derived owner. | +| `write(target, content, exec?, signal?)` | public | Builds the `FsExpectation` from recorded state, calls `createOrReplace`, refreshes state to `full`. Updating an existing file needs a prior `full` read; a create does not. | +| `edit(target, edit, exec?, signal?)` | public | Requires a prior `full` read by this owner (else `FS_NOT_OBSERVED` / `FS_PARTIAL_OBSERVATION`), rejects empty `oldString`, calls `applyEdit`, refreshes state. | +| `owner(exec?)` | helper | Derives the file-state owner (`exec.agent.session`) — `undefined` when there is none. | + +## Read-before-write/edit lives in the seam + +Write/edit safety depends on backend-defined target identity and version tokens, so `ctx.fs` — not the tool layer — records what each owner has observed (keyed by an opaque owner object, normally the agent session, then by `targetKey`) and enforces the policy. The base class owns owner derivation, the file-state store, and *which* `FsExpectation` to hand the backend; the backend owns version comparison and I/O. Only a `full` view authorizes write/edit; a `partial` view (paged/truncated read) records context but does not. + +State is held in a `WeakMap` keyed by the owner object and dropped on disposal (HMR safety). Persistence across sessions is deferred — a resumed session must read files again before write/edit. + +## Vocabulary + +`FsTarget` / `FsVersion` are opaque — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json new file mode 100644 index 0000000000..a0bce4940a --- /dev/null +++ b/packages/fs/fs/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-fs", + "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service, and the read-before-write/edit file-state contract", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts new file mode 100644 index 0000000000..b23d7dd90a --- /dev/null +++ b/packages/fs/fs/src/index.ts @@ -0,0 +1,256 @@ +/** + * The filesystem seam (`ctx.fs`): an abstract service defining WHAT a + * filesystem backend does — resolve paths into stable targets, read bounded + * text pages, create/replace files, and apply literal edits — without saying + * HOW. Implementations subclass {@link FileSystem} and register themselves as + * the `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the + * first. Future implementations swap in sandboxed, remote, virtual, or + * project-scoped backends without touching the tool schemas that consume them + * (`@deepseek-ai/dsh-tool-fs`). + * + * The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See + * the capability-seam RFC for why a swappable capability is three packages. + * + * ## Read-before-write/edit lives here, not in the tools + * + * Write/edit safety depends on backend-defined target identity and version + * tokens, so the seam — not the consumer — records what each owner has observed + * and enforces the policy. The base class owns owner derivation, the file-state + * store, and the decision of *which* {@link FsExpectation} to hand a backend; + * the backend owns version comparison and the actual I/O. A consumer passes its + * execution context through {@link read}/{@link write}/{@link edit} and never + * touches the cache, owner key, or version tokens. + * + * @module @deepseek-ai/dsh-fs + */ + +import { Context, Service } from 'cordis' +import { FsError } from './types.ts' +import type { + FsEditOutcome, + FsEditRequest, + FsExecContext, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsVersion, + FsWriteOutcome, + FileState, +} from './types.ts' + +export { + FsError, +} from './types.ts' +export type { + FsEditOutcome, + FsEditRequest, + FsErrorCode, + FsExecContext, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsStateSource, + FsTarget, + FsTextLine, + FsVersion, + FsView, + FsWriteOutcome, + FileState, +} from './types.ts' + +declare module 'cordis' { + interface Context { + fs: FileSystem + } +} + +/** + * Abstract filesystem service. Subclass, implement the four backend primitives + * ({@link resolve}, {@link readPage}, {@link createOrReplace}, + * {@link applyEdit}), and load the subclass as a plugin — it registers as + * `ctx.fs` (one implementation per context; loading a second throws, cordis' + * standard duplicate-service behavior). + * + * Consumers call the concrete public API ({@link read}/{@link write}/ + * {@link edit}), which derives the file-state owner, enforces the + * read-before-write/edit policy, and refreshes recorded state — then delegates + * the actual I/O to the backend primitives. + * + * Semantics every backend must honor: + * - {@link resolve} returns a stable {@link FsTarget}; the same underlying file + * reached by different input paths must yield the same `targetKey` so stale + * guards and file-state lookup agree across paths (e.g. through symlinks). + * - {@link readPage} returns line-numbered UTF-8 content with a `version` and a + * `view` (`full` only when the page covered the whole file). + * - {@link createOrReplace} honors the {@link FsExpectation}: `observed` + * rejects with `FS_STALE_VERSION` if the file changed since `version`; + * `partial` rejects existing targets because the owner saw only a + * non-editable view; `unobserved` creates iff the target is absent and + * otherwise rejects. + * - {@link applyEdit} verifies the expected version (stale guard) and is atomic + * (read-modify-write must not interleave with a concurrent edit). + */ +export abstract class FileSystem extends Service { + /** + * Observed-file state, keyed first by the owner object (weakly held, so a + * collected session frees its state), then by {@link FsTarget.targetKey}. + */ + private fileStates = new WeakMap>() + + constructor(ctx: Context) { + super(ctx, 'fs') + ctx.effect(() => () => { + // Drop all recorded state on disposal so a reloaded backend starts clean + // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes + // the release observable and immediate for tests. + this.fileStates = new WeakMap() + }, 'fs file-state teardown') + } + + // --- Backend primitives (subclass implements; all backend I/O lives here) --- + + /** + * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May + * perform I/O (a remote/sandboxed backend may need a round-trip to map a path + * to a stable identity), hence async even though the local backend only + * normalizes + realpaths. + */ + abstract resolve(path: string): Promise + + /** Read a bounded UTF-8 text page from a target. */ + abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise + + /** + * Create or fully replace a UTF-8 text file, honoring `expected` as the + * stale guard / create-vs-update decision. + */ + abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise + + /** + * Apply a literal edit to an existing UTF-8 text file, verifying + * `expected.version` as the stale guard. Atomic read-modify-write. + */ + abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise + + // --- Owner + file-state machinery (shared by all backends) --- + + /** + * Derive the file-state owner from an execution context — normally the active + * agent session. Returns `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. + */ + owner(exec?: FsExecContext): object | undefined { + return exec?.agent?.session + } + + /** Look up recorded state for an owner+target, if any. */ + protected getState(owner: object, targetKey: string): FileState | undefined { + return this.fileStates.get(owner)?.get(targetKey) + } + + /** Record (or replace) one owner's observed state for a target. */ + protected recordState(owner: object, state: FileState): void { + let byTarget = this.fileStates.get(owner) + if (!byTarget) { + byTarget = new Map() + this.fileStates.set(owner, byTarget) + } + byTarget.set(state.targetKey, state) + } + + // --- Concrete public API (orchestration; consumers call these) --- + + /** + * Read a bounded text page and, when an owner is derivable, record the + * observed state (a `full` view authorizes later write/edit; a `partial` view + * does not). + */ + async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { + const outcome = await this.readPage(target, request, signal) + const owner = this.owner(exec) + if (owner) { + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: outcome.view, + updatedAt: this.now(), + source: 'read', + }) + } + return outcome + } + + /** + * Create or fully replace a file. Updating an existing file requires a `full` + * prior observation by this owner; a create (no prior state, target absent) + * does not. After a successful write the recorded state refreshes to `full` + * at the new version so a follow-up modification needs no re-read. + */ + async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise { + const owner = this.owner(exec) + const prior = owner ? this.getState(owner, target.targetKey) : undefined + const expected: FsExpectation = prior + ? prior.view === 'full' + ? { kind: 'observed', version: prior.version } + : { kind: 'partial', version: prior.version } + : { kind: 'unobserved' } + + const outcome = await this.createOrReplace(target, content, expected, signal) + if (owner) { + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: 'full', + updatedAt: this.now(), + source: 'write', + }) + } + return outcome + } + + /** + * Apply a literal edit. Always requires a `full` prior observation by this + * owner. No owner or absent state rejects with `FS_NOT_OBSERVED`; a partial + * view rejects with `FS_PARTIAL_OBSERVATION`; an empty `oldString` rejects + * before backend I/O. There is no "create via edit". Refreshes recorded + * state to `full` at the new version on success. + */ + async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { + if (edit.oldString.length === 0) { + throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND') + } + const owner = this.owner(exec) + const prior = owner ? this.getState(owner, target.targetKey) : undefined + if (!owner || !prior) { + throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') + } + if (prior.view !== 'full') { + throw new FsError(`edit requires a full read of "${target.displayPath}" first`, 'FS_PARTIAL_OBSERVATION') + } + + const outcome = await this.applyEdit(target, edit, { version: prior.version }, signal) + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: 'full', + updatedAt: this.now(), + source: 'edit', + }) + return outcome + } + + /** + * Wall-clock now (ms). A protected seam so tests can use deterministic + * timestamps; production uses `Date.now()`. + */ + protected now(): number { + return Date.now() + } +} + +export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts new file mode 100644 index 0000000000..f08723731e --- /dev/null +++ b/packages/fs/fs/src/types.ts @@ -0,0 +1,194 @@ +/** + * Vocabulary for the filesystem capability seam (`ctx.fs`): the request/outcome + * shapes backends produce and consumers format, the opaque target/version + * identities, the per-owner file-state record, and the typed error taxonomy. + * + * These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and + * future sandboxed/remote backends) and by the model-facing consumer + * (`@deepseek-ai/dsh-tool-fs`). They deliberately avoid host-path assumptions: + * `targetKey` and `version` are opaque tokens, and `displayPath` is the only + * field a consumer may show. + * + * @module @deepseek-ai/dsh-fs/types + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Minimal structural view of a tool execution the filesystem seam needs to + * derive a file-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` + * satisfies this shape, so the consumer passes its `exec` straight through + * without `dsh-fs` 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); `dsh-fs` never reads any of its fields. + */ +export interface FsExecContext { + /** 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 + } +} + +/** + * A path resolved by a backend into a stable identity. `resolve()` produces + * this; every other operation takes it. + */ +export interface FsTarget { + /** The original model/plugin-supplied path, for diagnostics only. */ + inputPath: string + /** + * Opaque key for stale guards and file-state lookup. The local backend uses + * a realpath-like string; a remote backend might use a workspace URI or file + * id. Consumers MUST NOT parse it or assume it is a local absolute path. + */ + targetKey: string + /** + * Path for model/UI-facing output. May be a local absolute path, + * workspace-relative path, or remote URI depending on the backend. + */ + displayPath: string +} + +/** + * Opaque file-version token. The local backend derives it from mtime+size; a + * remote backend might use a revision id. `ctx.fs` records it for stale checks; + * consumers may display related metadata but MUST NOT interpret this token. + */ +export type FsVersion = string + +/** Resolved read window. The consumer applies its defaults/caps before calling. */ +export interface FsReadRequest { + /** 1-based first line to return. */ + offset: number + /** Maximum number of lines to return. */ + limit: number +} + +/** One line returned from a text file. */ +export interface FsTextLine { + /** 1-based line number in the file. */ + number: number + /** Line text without its trailing newline. */ + text: string +} + +/** Whether a recorded/returned view covers the whole file or only part of it. */ +export type FsView = 'full' | 'partial' + +/** Outcome of a bounded text read. */ +export interface FsReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Maximum number of lines requested. */ + limit: number + /** Returned lines, already numbered. */ + lines: FsTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ + truncatedByBytes?: true + /** Opaque version of the file at read time. */ + version: FsVersion + /** + * Whether this read saw the whole file (`full`) or only part of it + * (`partial`). Only a `full` view authorizes a later write/edit. + */ + view: FsView +} + +/** + * The read-before-write decision the base service hands to a backend for a + * full-file write. `observed` means the owner has a `full` view recorded at + * `version` (the backend rejects if the file has since changed); `partial` + * means the owner saw only a non-editable view of that target; `unobserved` + * means there is no prior view (the backend may create iff the target is + * absent, else rejects as not observed). + */ +export type FsExpectation = + | { kind: 'observed'; version: FsVersion } + | { kind: 'partial'; version: FsVersion } + | { kind: 'unobserved' } + +/** Outcome of a full-file write. */ +export interface FsWriteOutcome { + /** Whether the write created a new file or replaced an existing one. */ + operation: 'create' | 'update' + /** Opaque version of the file after the write. */ + version: FsVersion +} + +/** A literal-replacement edit request. */ +export interface FsEditRequest { + /** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */ + oldString: string + /** Literal replacement text. An empty string deletes the matched text. */ + newString: string + /** Replace every match instead of requiring exactly one. */ + replaceAll: boolean +} + +/** Outcome of a literal edit. */ +export interface FsEditOutcome { + /** Number of literal replacements applied. */ + replacements: number + /** Whether every match was replaced. */ + replaceAll: boolean + /** Opaque version of the file after the edit. */ + version: FsVersion +} + +/** Source that last touched a recorded {@link FileState}. */ +export type FsStateSource = 'read' | 'write' | 'edit' + +/** + * What an owner has observed about one target. Keyed (inside the service) first + * by the owner object, then by {@link FsTarget.targetKey}. Only a `full` view + * authorizes write/edit. + */ +export interface FileState { + /** Backend target identity this state describes. */ + targetKey: string + /** Display path captured when the state was recorded. */ + displayPath: string + /** Opaque version the owner last saw. */ + version: FsVersion + /** Whether the owner saw the whole file or only part of it. */ + view: FsView + /** Wall-clock time the state was last updated (ms since epoch). */ + updatedAt: number + /** Operation that produced this state. */ + source: FsStateSource +} + +/** + * Stable, machine-routable codes for filesystem failures. Carried on + * {@link FsError}; the tool registry surfaces `{ name, code }` on `isError` + * results so retry/permission/UI layers can branch without parsing messages. + */ +export type FsErrorCode = + | 'FS_NOT_FOUND' + | 'FS_NOT_TEXT' + | 'FS_NOT_REGULAR_FILE' + | 'FS_STALE_VERSION' + | 'FS_NOT_OBSERVED' + | 'FS_PARTIAL_OBSERVATION' + | 'FS_AMBIGUOUS_EDIT' + | 'FS_EDIT_NOT_FOUND' + | 'FS_ABORTED' + +/** + * Typed filesystem error. Extends {@link HarnessError} so it carries a stable + * {@link FsErrorCode} and chains `cause`. `dsh-fs` owns this vocabulary so + * backends and the policy layer raise the same codes instead of each inventing + * message strings. + */ +export class FsError extends HarnessError { + override readonly code: FsErrorCode + + constructor(message: string, code: FsErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.code = code + } +} diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts new file mode 100644 index 0000000000..84f84cbe0b --- /dev/null +++ b/packages/fs/fs/tests/service.spec.ts @@ -0,0 +1,313 @@ +/** + * Tests for the filesystem service seam itself: registration/disposal, owner + * derivation, and the read-before-write/edit policy the base class enforces + * (which `FsExpectation` it hands the backend, multi-owner isolation, and + * state refresh) — all exercised through a fake in-memory backend that records + * the expectations it received. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsView, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' + +/** A fake backend: an in-memory file table, recording every expectation it is handed. */ +class FakeFileSystem extends FileSystem { + files = new Map() + versions = new Map() + /** View the next `readPage` should report (tests flip this for partial reads). */ + nextReadView: FsView = 'full' + /** Expectations handed to `createOrReplace`, in call order. */ + writeExpectations: FsExpectation[] = [] + /** Versions handed to `applyEdit`, in call order. */ + editExpectedVersions: string[] = [] + + private bump(key: string): string { + const next = (this.versions.get(key) ?? 0) + 1 + this.versions.set(key, next) + return `v${next}` + } + + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: path, displayPath: path } + } + + override async readPage(target: FsTarget, request: FsReadRequest): Promise { + const content = this.files.get(target.targetKey) + if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND') + const allLines = content.split('\n') + const lines = allLines + .slice(request.offset - 1, request.offset - 1 + request.limit) + .map((text, i) => ({ number: request.offset + i, text })) + return { + offset: request.offset, + limit: request.limit, + lines, + totalLines: allLines.length, + version: `v${this.versions.get(target.targetKey) ?? 0}`, + view: this.nextReadView, + } + } + + override async createOrReplace(target: FsTarget, content: string, expected: FsExpectation): Promise { + this.writeExpectations.push(expected) + const existed = this.files.has(target.targetKey) + this.files.set(target.targetKey, content) + return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } + } + + override async applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: string }): Promise { + this.editExpectedVersions.push(expected.version) + 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: this.bump(target.targetKey) } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + return { ctx, fs } +} + +const READ_ALL: FsReadRequest = { offset: 1, limit: 2000 } +const ownerExec = (session: object) => ({ agent: { session } }) + +describe('FileSystem service seam', () => { + it('registers as ctx.fs and serves the API', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hi') + const outcome = await fs.read(await fs.resolve('a.txt'), READ_ALL) + expect(outcome.lines).toEqual([{ number: 1, text: 'hi' }]) + }) + + it('throws when a second implementation is loaded (duplicate service)', async () => { + const { ctx } = await setup() + await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow() + }) + + it('removes the service when the providing fiber is disposed', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(FakeFileSystem) + expect(ctx.fs).toBeDefined() + await fiber.dispose() + expect(ctx.fs).toBeUndefined() + }) +}) + +describe('owner derivation', () => { + it('derives the owner from exec.agent.session', async () => { + const { fs } = await setup() + const session = {} + expect(fs.owner(ownerExec(session))).toBe(session) + }) + + it('returns undefined with no exec, no agent, or no session', async () => { + const { fs } = await setup() + expect(fs.owner()).toBeUndefined() + expect(fs.owner({})).toBeUndefined() + expect(fs.owner({ agent: {} })).toBeUndefined() + }) +}) + +describe('read records observed state', () => { + it('a full read authorizes a later in-place write (observed expectation)', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, exec) + await fs.write(target, 'goodbye', exec) + + expect(fs.writeExpectations).toEqual([{ kind: 'observed', version: 'v0' }]) + }) + + it('a partial read does NOT authorize a write (passes a partial expectation)', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.nextReadView = 'partial' + const target = await fs.resolve('a.txt') + + await fs.read(target, { offset: 1, limit: 1 }, exec) + await fs.write(target, 'goodbye', exec) + + expect(fs.writeExpectations).toEqual([{ kind: 'partial', version: 'v0' }]) + }) + + it('skips recording when there is no owner', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL) // no exec + await fs.write(target, 'goodbye') // no exec → cannot be observed + + expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) + }) +}) + +describe('write policy', () => { + it('a create (no prior state) is unobserved', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('new.txt') + + const outcome = await fs.write(target, 'fresh', exec) + + expect(outcome.operation).toBe('create') + expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) + }) + + it('refreshes state to full after a write, so a follow-up edit needs no re-read', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('a.txt') + + await fs.write(target, 'one', exec) // create → state now full at v1 + await fs.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) + + expect(fs.editExpectedVersions).toEqual(['v1']) + }) +}) + +describe('edit policy', () => { + it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects with FS_PARTIAL_OBSERVATION when only a partial view was recorded', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.nextReadView = 'partial' + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, exec) + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) + + it('rejects an empty oldString before calling the backend primitive', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, exec) + + await expect( + fs.edit(target, { oldString: '', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(fs.editExpectedVersions).toEqual([]) + }) + + it('rejects when there is no owner (cannot prove prior observation)', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('proceeds after a full read, passing the recorded version as the stale guard', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.versions.set('a.txt', 7) // distinguishable version + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, exec) + + await fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) + + expect(fs.editExpectedVersions).toEqual(['v7']) + }) +}) + +describe('multi-owner isolation', () => { + it('owner A reading does not grant owner B edit authority', async () => { + const { fs } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, a) + + // B never read it → B's edit must be rejected. + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + // A still may edit. + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a), + ).resolves.toMatchObject({ replacements: 1 }) + }) + + it('each owner records its own observed version independently', async () => { + const { fs } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, a) // A sees v0 + await fs.write(target, 'mid', b) // B writes unobserved → file now v1 + await fs.write(target, 'late', a) // A still holds its v0 observation + + expect(fs.writeExpectations).toEqual([ + { kind: 'unobserved' }, + { kind: 'observed', version: 'v0' }, + ]) + }) +}) + +describe('disposal releases recorded state', () => { + it('a fresh provider after disposal starts with no inherited state', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(FakeFileSystem) + const fs1 = ctx.fs as FakeFileSystem + const exec = ownerExec({}) + fs1.files.set('a.txt', 'hello') + await fs1.read(await fs1.resolve('a.txt'), READ_ALL, exec) + await fiber.dispose() + + await ctx.plugin(FakeFileSystem) + const fs2 = ctx.fs as FakeFileSystem + fs2.files.set('a.txt', 'hello') + const target = await fs2.resolve('a.txt') + // Reusing the same exec/owner object: state must NOT carry over. + await expect( + fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +describe('FsError', () => { + it('carries a stable code and HarnessError name', () => { + const error = new FsError('nope', 'FS_NOT_FOUND') + expect(error.code).toBe('FS_NOT_FOUND') + expect(error.name).toBe('FsError') + expect(error).toBeInstanceOf(Error) + }) +}) diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json new file mode 100644 index 0000000000..7b250a29c4 --- /dev/null +++ b/packages/fs/fs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" } + ] +} diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md new file mode 100644 index 0000000000..2beb45be9c --- /dev/null +++ b/packages/fs/tool-fs/README.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-tool-fs + +The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the consumer third of the filesystem capability; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import). + +```ts ignore-check +// Load a ctx.fs provider first, then the tools. +await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local +await ctx.plugin(ToolFs) // this package — registers read/write/edit +``` + +Each tool also ships as a subpath plugin for focused deployments: + +```ts ignore-check +import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' +import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' +import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' +``` + +## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) + +| Tool | Arguments | Behavior | +|---|---|---| +| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. | +| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` (the backend enforces it); creating a new file does not. | +| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read`. | + +Field names are snake_case to match Claude Code and existing harness tool schemas. + +## How the read-before-write policy is enforced + +The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then calls `ctx.fs.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fs` derives the file-state owner (normally the agent session) from that context and owns the prior-observation and stale-version policy. Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. + +Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json new file mode 100644 index 0000000000..744a41736d --- /dev/null +++ b/packages/fs/tool-fs/package.json @@ -0,0 +1,51 @@ +{ + "name": "@deepseek-ai/dsh-tool-fs", + "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./read": { + "types": "./lib/read.d.ts", + "default": "./lib/read.js" + }, + "./write": { + "types": "./lib/write.d.ts", + "default": "./lib/write.js" + }, + "./edit": { + "types": "./lib/edit.d.ts", + "default": "./lib/edit.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts new file mode 100644 index 0000000000..3f65f5660d --- /dev/null +++ b/packages/fs/tool-fs/src/edit.ts @@ -0,0 +1,82 @@ +/** + * The model-facing `edit` tool: update an existing UTF-8 text file by replacing + * literal text, requiring a unique match by default. Execution goes through + * `ctx.fs`, which enforces prior observation and the stale-version guard and + * owns the literal-match semantics. + * + * @module @deepseek-ai/dsh-tool-fs/edit + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** Validated `edit` arguments after defaulting. */ +interface EditInput { + filePath: string + oldString: string + newString: string + replaceAll: boolean +} + +/** Validate value constraints the schema DSL can't express. */ +export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string') + if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ') + return { + filePath: args.file_path, + oldString: args.old_string, + newString: args.new_string, + replaceAll: args.replace_all ?? false, + } +} + +/** Format an edit outcome as a Claude-style model-facing success message. */ +export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string { + return outcome.replaceAll + ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.` + : `The file ${displayPath} has been updated successfully.` +} + +/** Register the `edit` tool and its system-prompt guidance. */ +export function apply(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:edit', + order: 102, + text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true.', + }) + + ctx.tools.register(defineTool({ + name: 'edit', + description: 'Edit an existing UTF-8 text file by replacing literal text.', + parameters: { + file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' }, + old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' }, + new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' }, + replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' }, + }, + async execute(args, exec): Promise { + const input = parseEditArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.edit( + target, + { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, + exec, + exec.signal, + ) + return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-edit' + +/** Services required by the `edit` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts new file mode 100644 index 0000000000..437c16b5dd --- /dev/null +++ b/packages/fs/tool-fs/src/index.ts @@ -0,0 +1,35 @@ +/** + * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the + * `ctx.fs` seam. This root plugin registers all three tools by composing the + * per-tool registration helpers; each tool is also exposed as a subpath plugin + * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused deployments. + * + * The package owns model-facing concerns only — tool names, JSON schemas, + * argument validation, prompt sections, result formatting. All filesystem + * execution goes through `ctx.fs`; this package never imports `node:fs`, + * `node:path`, or an `@deepseek-ai/dsh-fs-local` implementation. + * + * @module @deepseek-ai/dsh-tool-fs + */ + +import type { Context } from 'cordis' +import { applyReadTool } from './read.ts' +import { applyWriteTool } from './write.ts' +import { applyEditTool } from './edit.ts' + +export { READ_LIMIT, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts' +export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' +export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tool-fs' + +/** Services required by the filesystem tool suite. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Register the full `read`/`write`/`edit` filesystem tool suite. */ +export function apply(ctx: Context): void { + applyReadTool(ctx) + applyWriteTool(ctx) + applyEditTool(ctx) +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts new file mode 100644 index 0000000000..bfa67a588f --- /dev/null +++ b/packages/fs/tool-fs/src/read.ts @@ -0,0 +1,95 @@ +/** + * The model-facing `read` tool: inspect a UTF-8 text file and return + * line-numbered content with pagination guidance. Execution goes through + * `ctx.fs` — this module owns only the model-facing schema, argument + * validation, and result formatting, never filesystem I/O. + * + * @module @deepseek-ai/dsh-tool-fs/read + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { FsReadOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** Default and maximum number of lines returned by one `read` call. */ +export const READ_LIMIT = 2000 + +/** Validated `read` arguments after defaulting. */ +interface ReadInput { + filePath: string + offset: number + limit: number +} + +function parsePositiveInteger(value: number, name: string): number { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer`) + } + return value +} + +/** Validate value constraints the schema DSL can't express. */ +export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset') + const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit') + if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`) + return { filePath: args.file_path, offset, limit } +} + +/** Format a read outcome as one OpenCode-style line-numbered text block body. */ +export function formatReadOutput(displayPath: string, outcome: FsReadOutcome): string { + const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1) + let footer: string + if (outcome.truncatedByBytes) { + footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)` + } else if (endLine < outcome.totalLines) { + footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)` + } else { + footer = `(End of file - total ${outcome.totalLines} lines)` + } + const body = outcome.lines.length > 0 + ? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}` + : footer + return `${displayPath} +file + +${body} +` +} + +/** Register the `read` tool and its system-prompt guidance. */ +export function apply(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:read', + order: 100, + text: 'Use the read tool to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.', + }) + + ctx.tools.register(defineTool({ + name: 'read', + description: 'Read a UTF-8 text file and return line-numbered content.', + parameters: { + file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' }, + offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, + limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` }, + }, + async execute(args, exec): Promise { + const input = parseReadArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) + return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-read' + +/** Services required by the `read` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts new file mode 100644 index 0000000000..ff66d10127 --- /dev/null +++ b/packages/fs/tool-fs/src/write.ts @@ -0,0 +1,63 @@ +/** + * The model-facing `write` tool: create or fully replace a UTF-8 text file. + * Execution goes through `ctx.fs`, which enforces the read-before-overwrite + * policy (updating an existing file requires a prior read in the same + * execution context; creating a new file does not). + * + * @module @deepseek-ai/dsh-tool-fs/write + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** Validate value constraints the schema DSL can't express. */ +export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + return { filePath: args.file_path, content: args.content } +} + +/** Format a write outcome as one model-facing text block body. */ +export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string { + const verb = outcome.operation === 'create' ? 'Created' : 'Updated' + return `${displayPath} +file + +${verb} file +` +} + +/** Register the `write` tool and its system-prompt guidance. */ +export function apply(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:write', + order: 101, + text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the backend requires it) and prefer edit for targeted changes.', + }) + + ctx.tools.register(defineTool({ + name: 'write', + description: 'Create or fully replace a UTF-8 text file.', + parameters: { + file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' }, + content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' }, + }, + async execute(args, exec): Promise { + const input = parseWriteArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.write(target, input.content, exec, exec.signal) + return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-write' + +/** Services required by the `write` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts new file mode 100644 index 0000000000..6f81763241 --- /dev/null +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -0,0 +1,143 @@ +/** + * Integration tests: the real local backend (`dsh-fs-local`) plus the model + * tools (`dsh-tool-fs`), exercised through `ctx.tools.execute()` so nothing + * bypasses the tool registry. These verify the WORLD — files are read back from + * disk and asserted byte-for-byte — not the tool's self-report. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +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 ToolFs from '@deepseek-ai/dsh-tool-fs' + +let dir: string +let ctx: Context +let fiber: Awaited> +// A stable session object stands in for an agent session (the file-state owner). +const session = {} + +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 }) + fiber = await ctx.plugin(ToolFs) +}) +afterEach(async () => { + await fiber.dispose() + await rm(dir, { recursive: true, force: true }) +}) + +let callCounter = 0 +function call(name: string, args: unknown) { + return ctx.tools.execute({ + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + agent: { session } as never, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('write → disk', () => { + it('creates a file with exactly the requested bytes', async () => { + const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n') + }) + + it('rejects overwriting an existing file without reading it first', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + // The world is unchanged. + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') + }) + + it('allows overwriting after a read', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced') + }) +}) + +describe('read', () => { + it('returns line-numbered content', async () => { + await writeFile(join(dir, 'a.txt'), 'alpha\nbeta') + const result = await call('read', { file_path: 'a.txt' }) + expect(text(result)).toContain('1: alpha') + expect(text(result)).toContain('2: beta') + expect(text(result)).toContain('(End of file - total 2 lines)') + }) + + it('reports a binary file as an error', async () => { + await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02])) + const result = await call('read', { file_path: 'bin' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' }) + }) +}) + +describe('edit → disk', () => { + it('applies a unique literal replacement after a read', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('rejects an edit before any read, leaving the file untouched', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') + }) + + it('rejects an edit after only a partial read, leaving the file untouched', async () => { + await writeFile(join(dir, 'a.txt'), 'hello\nworld') + await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello\nworld') + }) + + it('rejects an ambiguous match without replace_all', async () => { + await writeFile(join(dir, 'a.txt'), 'a a a') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a') + }) + + it('replaces all matches with replace_all', async () => { + await writeFile(join(dir, 'a.txt'), 'a a a') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b') + }) + + it('supports a full write→edit cycle without an intervening read', async () => { + await call('write', { file_path: 'a.txt', content: 'one two' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three') + }) +}) diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts new file mode 100644 index 0000000000..ac35babe04 --- /dev/null +++ b/packages/fs/tool-fs/tests/subpaths.spec.ts @@ -0,0 +1,74 @@ +/** + * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, + * `/write`, `/edit`): each registers exactly one tool, injects the same + * services, and cleans up on disposal. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { FileSystem } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsReadOutcome, + FsTarget, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' +import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' +import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' + +class StubFs extends FileSystem { + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: path, displayPath: path } + } + override async readPage(): Promise { + return { offset: 1, limit: 1, lines: [], totalLines: 0, version: 'v', view: 'full' } + } + override async createOrReplace(): Promise { + return { operation: 'create', version: 'v' } + } + override async applyEdit(): Promise { + return { replacements: 1, replaceAll: false, version: 'v' } + } +} + +async function base() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(StubFs) + return ctx +} + +describe('subpath plugins', () => { + it('each registers exactly its one tool', async () => { + const cases: Array<[unknown, string]> = [ + [readPlugin, 'read'], + [writePlugin, 'write'], + [editPlugin, 'edit'], + ] + for (const [plugin, toolName] of cases) { + const ctx = await base() + await ctx.plugin(plugin as Parameters[0]) + expect(ctx.tools.schemas().map(s => s.name)).toEqual([toolName]) + } + }) + + it('cleans up on disposal (HMR safety)', async () => { + const ctx = await base() + const fiber = await ctx.plugin(readPlugin as Parameters[0]) + expect(ctx.tools.schemas()).toHaveLength(1) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('stays pending without a ctx.fs provider', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(writePlugin as Parameters[0]) + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts new file mode 100644 index 0000000000..594a07dbbd --- /dev/null +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -0,0 +1,270 @@ +/** + * Consumer-surface tests for the filesystem tools using a fake `ctx.fs` that + * records the execution context it received and returns canned outcomes. These + * verify schemas, argument validation, result formatting, FsError→isError + * propagation, and that each tool passes `exec` straight through to `ctx.fs`. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExecContext, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs' + +/** + * Records the public-API calls (and the exec each received) and returns canned + * outcomes; lets a test arm a rejection. Overrides the public methods directly + * (not the primitives) so we observe exactly what the tool passed. + */ +class FakeFs extends FileSystem { + calls: Array<{ op: string; exec: FsExecContext | undefined; target: FsTarget }> = [] + rejectWith?: FsError + + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: `key:${path}`, displayPath: `/abs/${path}` } + } + + override async readPage(): Promise { + throw new Error('not used: tool tests override read()') + } + override async createOrReplace(): Promise { + throw new Error('not used') + } + override async applyEdit(): Promise { + throw new Error('not used') + } + + override async read(target: FsTarget, _request: FsReadRequest, exec?: FsExecContext): Promise { + this.calls.push({ op: 'read', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { + offset: 1, + limit: 2000, + lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }], + totalLines: 2, + version: 'v1', + view: 'full', + } + } + + override async write(target: FsTarget, _content: string, exec?: FsExecContext): Promise { + this.calls.push({ op: 'write', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { operation: 'create', version: 'v1' } + } + + override async edit(target: FsTarget, _edit: FsEditRequest, exec?: FsExecContext): Promise { + this.calls.push({ op: 'edit', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { replacements: 1, replaceAll: false, version: 'v1' } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + await ctx.plugin(ToolFs) + const fs = ctx.fs as FakeFs + return { ctx, fs } +} + +let callCounter = 0 +function call(ctx: Context, name: string, args: unknown, agent?: object) { + return ctx.tools.execute({ + callId: CallId(`call-${++callCounter}`), + name, + arguments: args, + ...agent ? { agent: agent as never } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('registration', () => { + it('registers read, write, and edit', async () => { + const { ctx } = await setup() + expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write']) + }) + + it('registers prompt sections for each tool', async () => { + const { ctx } = await setup() + const prompt = renderPrompt(await ctx.systemPrompt.assemble()) + expect(prompt).toContain('Use the read tool') + expect(prompt).toContain('Use the write tool') + expect(prompt).toContain('Use the edit tool') + }) + + it('stays pending until ctx.fs exists (inject)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(ToolFs) // no fs provider + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('unregisters everything on fiber disposal (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + const fiber = await ctx.plugin(ToolFs) + expect(ctx.tools.schemas()).toHaveLength(3) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) + +describe('read tool', () => { + it('formats line-numbered content with a footer', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe(`/abs/a.txt +file + +1: hello +2: world + +(End of file - total 2 lines) +`) + }) + + it('rejects a non-positive offset via arg validation', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('offset must be a positive integer') + }) + + it('rejects a limit above the cap', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('less than or equal to 2000') + }) + + it('rejects a blank file_path', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: ' ' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('file_path must be a non-empty string') + }) + + it('passes the execution context through to ctx.fs', async () => { + const { ctx, fs } = await setup() + const session = {} + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + expect(fs.calls).toHaveLength(1) + expect(fs.calls[0]?.op).toBe('read') + expect(fs.calls[0]?.exec?.agent?.session).toBe(session) + }) +}) + +describe('formatReadOutput footer variants', () => { + const base = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: 'v', view: 'full' as const } + + it('reports a byte-capped read', () => { + const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true }) + expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)') + }) + + it('reports a more-remaining page', () => { + const out = formatReadOutput('/f', { ...base, totalLines: 99 }) + expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)') + }) + + it('reports end-of-file', () => { + expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)') + }) + + it('renders an empty file as just the footer', () => { + const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 }) + expect(out).toContain('(End of file - total 0 lines)') + expect(out).not.toContain(': ') + }) +}) + +describe('write tool', () => { + it('formats a create result', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('Created file') + }) + + it('rejects a blank file_path', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('file_path must be a non-empty string') + }) + + it('propagates a backend FsError as an isError result carrying its code', async () => { + const { ctx, fs } = await setup() + fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION') + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' }) + }) +}) + +describe('edit tool', () => { + it('formats a single-replacement success', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.') + }) + + it('rejects identical old/new strings', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('must differ') + }) + + it('rejects an empty old_string', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('old_string must be a non-empty string') + }) + + it('rejects a blank file_path', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('file_path must be a non-empty string') + }) + + it('propagates FS_NOT_OBSERVED from the backend', async () => { + const { ctx, fs } = await setup() + fs.rejectWith = new FsError('read first', 'FS_NOT_OBSERVED') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('propagates FS_PARTIAL_OBSERVATION from the backend', async () => { + const { ctx, fs } = await setup() + fs.rejectWith = new FsError('read fully first', 'FS_PARTIAL_OBSERVATION') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) +}) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json new file mode 100644 index 0000000000..ee5a853c91 --- /dev/null +++ b/packages/fs/tool-fs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" }, + { "path": "../../core/system-prompt" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/tool-fs/tsdown.config.ts b/packages/fs/tool-fs/tsdown.config.ts new file mode 100644 index 0000000000..131735d482 --- /dev/null +++ b/packages/fs/tool-fs/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * tool-fs exposes one package root plus one entry per tool plugin, so each tool + * can be loaded or replaced independently as a subpath plugin + * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`). The root tsdown config + * only auto-discovers `src/index.ts`, so the subpath entries are declared here. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/read.ts', 'src/write.ts', 'src/edit.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bca330308..cae3a42202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,58 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/fs/fs: + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/fs/fs-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/fs/tool-fs: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../fs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/llm/llm: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e2070fe28..ad08d09468 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,6 +34,9 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "@deepseek-ai/dsh-tool-fs/read": ["./packages/fs/tool-fs/src/read.ts"], + "@deepseek-ai/dsh-tool-fs/write": ["./packages/fs/tool-fs/src/write.ts"], + "@deepseek-ai/dsh-tool-fs/edit": ["./packages/fs/tool-fs/src/edit.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit @@ -43,6 +46,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/fs/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 27a17a3f17..ea3882b873 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -26,6 +26,9 @@ { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/fs/fs" }, + { "path": "./packages/fs/fs-local" }, + { "path": "./packages/fs/tool-fs" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 54769bdbc0..0522b9649c 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -16,10 +16,14 @@ "@cordisjs/plugin-timer": ["./vendor/timer/lib"], "@cordisjs/plugin-hmr": ["./vendor/hmr/lib"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/lib/shared"], + "@deepseek-ai/dsh-tool-fs/read": ["./packages/fs/tool-fs/src/read.ts"], + "@deepseek-ai/dsh-tool-fs/write": ["./packages/fs/tool-fs/src/write.ts"], + "@deepseek-ai/dsh-tool-fs/edit": ["./packages/fs/tool-fs/src/edit.ts"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/fs/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src",