Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/cordis.yml
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	packages/bash/bash-sandbox/src/index.ts
#	packages/bash/bash/src/types.ts
#	packages/bash/tool-bash/src/index.ts
#	packages/fs/fs/src/index.ts
#	packages/sandbox/sandbox-policy/src/session-mode.ts
#	packages/ui/permission/src/index.ts
#	packages/ui/permission/tests/permission.spec.ts
#	scripts/doc-budgets.manifest.json
This commit is contained in:
kingwl
2026-07-14 21:25:36 +08:00
608 changed files with 7195 additions and 12508 deletions

View File

@@ -42,3 +42,14 @@ This package declares three events (see the generated [events catalog](../../../
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. 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_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `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.
## Model Experience
Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results.
## Known Limitations and Deferred Work
- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md).
- **Seven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md).
- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)).
- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend.

View File

@@ -1,59 +1,8 @@
/**
* The filesystem provider seam (`ctx.fs`): an abstract service defining the
* text-storage primitives a backend provides — resolve a path into a stable
* target, stat its metadata, read/stream its text, write it atomically with an
* explicit intent, and apply a guarded literal edit — 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 model-facing tool schemas
* (`@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 (here four)
* packages.
*
* ## This is a provider seam, not the policy layer
*
* `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns
* UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the
* literal-edit critical section — but NOT line windows, numbered lines,
* rendered footers, or observed-state. Read windowing lives in the model-facing
* tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit
* are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*`
* event gate. So a sandboxed/remote backend inherits no model-facing observation
* policy it has no business carrying.
*
* `editText` stays on this seam (not composed in the policy layer from a read
* plus a write) because version guard + literal match + atomic rewrite must
* stay inside one mutation critical section for correct error attribution and
* one-wins/one-stale concurrency, and a remote backend may implement it as a
* native compare-and-edit.
*
* ## The version guard is OPTIONAL — additive policy, not subtractive
*
* `ctx.fs` on its own is a complete, unconstrained text-storage seam: `read`
* reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally
* replaces literal text in the current content. Both mutations take their
* version guard as an OPTIONAL argument — omit it for the unconstrained
* bare-provider behavior, supply it to guard against a concurrent change. The
* mutation runs inside the backend's per-target lock either way, so an
* unconditional write/edit is still atomic; "unconditional" drops the *version*
* precondition, not the atomicity. Observed-state, read-before-edit, and
* version-guarded write/edit are NOT provider behavior — they are policy a
* plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard.
*
* ## The fs policy events live here, not in the policy plugin
*
* This package owns the `fs/write-intent`, `fs/edit-intent`, and
* `fs/observed` event vocabulary (see {@link Events}). The emitter is
* `@deepseek-ai/dsh-tool-fs` and the default listener is
* `@deepseek-ai/dsh-fs-policy`; the events live in the one package both
* already depend on, so the emitter shares a vocabulary with the policy listener
* without depending on the policy plugin. The events carry only `dsh-fs`
* vocabulary plus an opaque `object` actor — no model-facing concepts (line
* windows, numbered lines) and no agent/session owner structure leak down.
*
* Filesystem text-storage provider seam. Backends own stable target identity,
* text decoding, binary rejection, and atomic mutations. Read windows and
* observed-state policy stay in consumer and policy plugins; `editText` remains
* here so version check, literal match, and rewrite share one critical section.
* @module @deepseek-ai/dsh-fs
*/
@@ -93,44 +42,25 @@ declare module 'cordis' {
interface Events {
/**
* Single-slot decision: produce the write intent for the next
* {@link FileSystem.writeText}. The tool dispatches this as an unbound
* waterfall (no `this`) and supplies a default thunk returning `undefined`
* (unconditional create-or-overwrite — the bare provider). The
* `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent`
* (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }`
* (observed) and does NOT call `next()` — one decision, not a composable
* chain. The slot is first-wins: the first non-`next()` decider (registration
* order, or `prepend`) occupies it; a second decider is a misconfiguration,
* not layering. `actor` is the opaque tool-execution context, never read here.
* Single-slot decision for the next {@link FileSystem.writeText}. Calling
* `next()` yields the bare provider's unconditional write; the first listener
* that returns an intent owns the decision rather than composing with peers.
* @param target - the resolved target about to be written.
* @param actor - the opaque tool-execution context the decider keys off.
* @mode waterfall
*/
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
/**
* Single-slot decision: produce the optional version guard for the next
* {@link FileSystem.editText}. The tool dispatches this as an unbound
* waterfall and supplies a default thunk returning `undefined` (unconditional
* edit of the current content — the bare provider; no `stat`). The
* `@deepseek-ai/dsh-fs-policy` policy listener returns
* `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
* or has not observed the target. Does NOT call `next()`: one decision,
* first-wins (see {@link Events.'fs/write-intent'}).
* Single-slot decision for the next {@link FileSystem.editText}. Calling
* `next()` yields an unconditional edit; the first returned guard wins.
* @param target - the resolved target about to be edited.
* @param actor - the opaque tool-execution context the decider keys off.
* @mode waterfall
*/
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
/**
* Record that an actor observed a target at a version, after a successful
* read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a
* synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s
* is a `WeakMap.set`): the tool does not guard the emit, so a listener that
* throws surfaces as the tool's `isError` result, and cordis `emit` does not
* await listener promises — async or fallible audit/telemetry does not
* belong here. No listener ⇒ nothing recorded. `actor` is the opaque
* tool-execution context.
* Record a successful observation. Listeners must be synchronous recorders:
* throws fail the tool call and returned promises are not awaited.
* @param target - the target that was read/written/edited.
* @param version - the version the actor now holds as its observation.
* @param actor - the observing tool-execution context; undefined records nothing useful.
@@ -141,40 +71,17 @@ declare module 'cordis' {
}
/**
* Abstract filesystem provider service. Subclass, implement the seven storage
* primitives, 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).
*
* 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 target lookup agree across paths (e.g. through symlinks).
* - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined`
* when the target is absent.
* - {@link readText}/{@link streamText} read the whole regular text file (the
* stream for large files); both own regular-file checks, UTF-8 decoding,
* binary/NUL rejection, and `FS_NOT_TEXT`.
* - {@link listDir} returns direct children of a directory in stable name order
* with resolved child targets and cheap metadata only. It never reads file
* contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw
* `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and
* other backend I/O failures throw `FS_IO_ERROR`.
* - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL:
* omit it for an unconditional create-or-overwrite (the bare-provider default),
* or supply a {@link FsWriteIntent} to guard the write.
* - {@link editText} verifies `expected.version` BEFORE literal matching (so a
* stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/
* `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement
* and writes atomically — all inside one mutation critical section. `expected`
* is OPTIONAL: omit it for an unconditional edit of the current content (a
* missing target still reports `FS_STALE_VERSION`).
* Abstract filesystem provider. Targets must preserve identity across aliases;
* reads expose regular UTF-8 text or typed errors, listings are stable and
* content-free, and mutations are atomic. Optional guards add stale protection
* without changing the unguarded provider contract.
*/
export abstract class FileSystem extends Service {
constructor(ctx: Context) {
super(ctx, 'fs')
}
/**
/**
* The sandbox mode this backend enforces on mutations BY DEFAULT, or
* `undefined` when it does not confine at all — the capability fact the tool
@@ -192,18 +99,10 @@ export abstract class FileSystem extends Service {
}
/**
* 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.
* 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.
*
* `opts.cwd` is the base directory a RELATIVE `path` resolves against; an
* absolute `path` ignores it. Omitted ⇒ the backend's own default base (the
* local backend uses its configured `cwd`). The CALLER supplies this — the
* seam does not read a session or agent — so a tool can resolve against the
* caller's per-session workspace (`exec.agent.session.header.cwd`) without the
* provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
* defaults a bash `workdir` to the session cwd.
* @param path - the path to resolve; relative paths resolve against `opts.cwd`.
* @param opts - `cwd` overrides the backend's default base for relative paths.
* @returns the stable target; the same file yields the same `targetKey`.
@@ -247,10 +146,8 @@ export abstract class FileSystem extends Service {
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
/**
* Create or fully replace a UTF-8 text file atomically. `expected` is the
* create-vs-replace decision and stale guard when supplied; OMITTING it is an
* unconditional create-or-overwrite (the bare provider — no version guard, no
* read-first requirement). Atomic either way.
* Atomically create or replace UTF-8 text. `expected` guards intent and
* staleness; omission allows unconditional overwrite.
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
@@ -269,11 +166,9 @@ export abstract class FileSystem extends Service {
): Promise<FsWriteOutcome>
/**
* Apply a literal edit to an existing UTF-8 text file. When `expected` is
* supplied, verifies `expected.version` as the stale guard BEFORE literal
* matching; OMITTING it edits the current content unconditionally (no version
* guard). Either way applies the replacement and writes atomically — one
* mutation critical section — and a missing target reports `FS_STALE_VERSION`.
* Atomically edit literal text. When supplied, the version guard is checked
* before matching so stale content reports `FS_STALE_VERSION`; omission edits
* the current content without a freshness precondition.
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.

View File

@@ -1,21 +1,7 @@
/**
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque
* target/version identities, the metadata `stat` returns, the write-intent
* and outcome shapes, the literal-edit request/outcome, 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 policy layer
* (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage*
* vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand
* back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey`
* and `version` are opaque branded tokens, and `displayPath` is the only field a
* consumer may show.
*
* Model-facing concepts (line windows, numbered lines, observed-state) do NOT
* live here; they belong to the consumer tool and the policy plugin
* (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`).
*
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque target/version
* identities, the metadata `stat` returns, the write-intent and outcome shapes, the
* literal-edit request/outcome, and the typed error taxonomy.
* @module @deepseek-ai/dsh-fs/types
*/
@@ -104,17 +90,10 @@ export interface FsDirEntry {
}
/**
* The explicit intent of a guarded {@link FileSystem.writeText} call.
* `createIfAbsent` creates a missing target and rejects an existing one with
* `FS_NOT_OBSERVED` (the path the policy plugin uses when the owner has no prior
* read). `replaceIfVersion` replaces only when the target exists at the observed
* version; a missing target or a version mismatch throws `FS_STALE_VERSION`.
*
* `writeText` takes this OPTIONALLY: omitting `expected` is the third,
* unconstrained state — an unconditional create-or-overwrite (the bare
* provider). The union itself carries only the two GUARDED intents; "no guard"
* is expressed by omission, so the write and edit mutations share one symmetric
* shape (`expected?`: omit = unconditional, present = guarded).
* Guarded write intent. `createIfAbsent` rejects an existing target with
* `FS_NOT_OBSERVED`; `replaceIfVersion` rejects absence or mismatch with
* `FS_STALE_VERSION`. Omitting the intent from `writeText` means unconditional
* create-or-overwrite, not a third union arm.
*/
export type FsWriteIntent =
| { kind: 'createIfAbsent' }