@banbolee/dsh-fish-shell 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +310 -13
  3. package/README.zh.md +99 -4
  4. package/THIRD-PARTY-NOTICES.md +91 -0
  5. package/cordis.patch.yml +56 -0
  6. package/node_modules/@deepseek-ai/cosmokit/LICENSE +21 -0
  7. package/node_modules/@deepseek-ai/cosmokit/README.md +30 -0
  8. package/node_modules/@deepseek-ai/cosmokit/lib/index.js +418 -0
  9. package/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts +15 -0
  10. package/node_modules/@deepseek-ai/cosmokit/lib/types/array.d.ts.map +1 -0
  11. package/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts +13 -0
  12. package/node_modules/@deepseek-ai/cosmokit/lib/types/index.d.ts.map +1 -0
  13. package/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts +41 -0
  14. package/node_modules/@deepseek-ai/cosmokit/lib/types/misc.d.ts.map +1 -0
  15. package/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts +89 -0
  16. package/node_modules/@deepseek-ai/cosmokit/lib/types/string.d.ts.map +1 -0
  17. package/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts +19 -0
  18. package/node_modules/@deepseek-ai/cosmokit/lib/types/time.d.ts.map +1 -0
  19. package/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts +42 -0
  20. package/node_modules/@deepseek-ai/cosmokit/lib/types/types.d.ts.map +1 -0
  21. package/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts +40 -0
  22. package/node_modules/@deepseek-ai/cosmokit/lib/types/volatile.d.ts.map +1 -0
  23. package/node_modules/@deepseek-ai/cosmokit/package.json +32 -0
  24. package/node_modules/@deepseek-ai/cosmokit/src/array.ts +42 -0
  25. package/node_modules/@deepseek-ai/cosmokit/src/index.ts +12 -0
  26. package/node_modules/@deepseek-ai/cosmokit/src/misc.ts +78 -0
  27. package/node_modules/@deepseek-ai/cosmokit/src/string.ts +113 -0
  28. package/node_modules/@deepseek-ai/cosmokit/src/time.ts +92 -0
  29. package/node_modules/@deepseek-ai/cosmokit/src/types.ts +168 -0
  30. package/node_modules/@deepseek-ai/cosmokit/src/volatile.ts +86 -0
  31. package/node_modules/@deepseek-ai/dsh-tool-terminal/LICENSE +21 -0
  32. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.i18n.yaml +100 -0
  33. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.md +186 -0
  34. package/node_modules/@deepseek-ai/dsh-tool-terminal/README.zh.md +186 -0
  35. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/index.js +646 -0
  36. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/background.d.ts +20 -0
  37. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/index.d.ts +32 -0
  38. package/node_modules/@deepseek-ai/dsh-tool-terminal/lib/types/render.d.ts +81 -0
  39. package/node_modules/@deepseek-ai/dsh-tool-terminal/package.json +63 -0
  40. package/node_modules/@deepseek-ai/schemastery/LICENSE +21 -0
  41. package/node_modules/@deepseek-ai/schemastery/README.md +393 -0
  42. package/node_modules/@deepseek-ai/schemastery/lib/index.cjs +635 -0
  43. package/node_modules/@deepseek-ai/schemastery/lib/index.mjs +635 -0
  44. package/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts +211 -0
  45. package/node_modules/@deepseek-ai/schemastery/lib/types/index.d.ts.map +1 -0
  46. package/node_modules/@deepseek-ai/schemastery/package.json +39 -0
  47. package/node_modules/@deepseek-ai/schemastery/src/index.ts +962 -0
  48. package/node_modules/@standard-schema/spec/LICENSE +21 -0
  49. package/node_modules/@standard-schema/spec/README.md +198 -0
  50. package/node_modules/@standard-schema/spec/dist/index.cjs +18 -0
  51. package/node_modules/@standard-schema/spec/dist/index.d.cts +119 -0
  52. package/node_modules/@standard-schema/spec/dist/index.d.ts +119 -0
  53. package/node_modules/@standard-schema/spec/dist/index.js +0 -0
  54. package/node_modules/@standard-schema/spec/package.json +52 -0
  55. package/package.json +17 -3
  56. package/terminal-tools.js +259 -0
  57. package/terminal.js +297 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,74 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@banbolee/dsh-fish-shell` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.8.0] - 2026-10-09
9
+
10
+ This is a **minor** release (`0.7.0` → `0.8.0`): a new capability, no breaking
11
+ change — the one-shot `fish` path is untouched.
12
+
13
+ ### Added
14
+
15
+ - Interactive terminal sessions (L2): the bundle patch now appends a
16
+ `fish-terminal-group` (`cordis:group` + `isolate: { terminals: true }`) that
17
+ mounts the official `@deepseek-ai/dsh-terminal`, this bundle's `terminal.js`
18
+ (the official `@deepseek-ai/dsh-terminal-bash` backend driven with fish argv,
19
+ plus the harness home contract below), the official
20
+ `@deepseek-ai/dsh-tool-terminal`, and this bundle's `terminal-tools` policy.
21
+ The model gets six `terminal_*` tools — `terminal_open`, `terminal_send`,
22
+ `terminal_read`, `terminal_signal`, `terminal_close`, `terminal_list` — a
23
+ real fish PTY instead of only one-shot `fish -c`.
24
+ - **Harness home contract in interactive sessions.** `terminal.js` adds
25
+ `DSH_HOME` — and `DSH_PROFILE` / `DSH_PROFILE_DIR` when the profile context
26
+ carries them — to the PTY child environment, from the same `ctx.shellEnv`
27
+ registry the one-shot `fish` tool reads. A `dsh` nested inside an interactive
28
+ session now starts on the same harness home and plugin set out of the box;
29
+ before this change it resolved the default `~/.dsh` and had to be pointed at
30
+ the real home by hand (`env -u DSH_SESSION_ID … DSH_HOME=<home> dsh --profile
31
+ <profile>`).
32
+ - The `terminalTools` switch (`terminal-tools.js`, exported as
33
+ `@banbolee/dsh-fish-shell/terminal-tools`): `allow` (default) or `deny`
34
+ (restrict the six explicitly enumerated tool names per agent).
35
+
36
+ ### Changed
37
+
38
+ - `@deepseek-ai/dsh-tool-terminal@0.2.0-rc.1` now ships with the package: it is
39
+ a `dependencies` + `bundledDependencies` entry, so packing requires the
40
+ hoisted linker (`pnpm install --prod --config.node-linker=hoisted` →
41
+ `pnpm pack --config.node-linker=hoisted`). An offline profile install
42
+ resolves it from inside the tarball instead of the profile plane.
43
+ - `scripts/sync-to-profile.sh` now takes a `<profile>` argument and deploys the
44
+ packed tarball (`dsh plugin --profile <profile> add -w <tgz> --offline
45
+ --config.auto-install-peers=false`) instead of copying a hand-written list of
46
+ source files.
47
+ - The L2 backend row keeps the official backend's configuration contract:
48
+ `terminal.js` re-exports the official `Config` schema and reproduces the
49
+ official configuration resolution and validation (per-dialect defaults,
50
+ validation messages), so an empty `shellPath`/`shellArgs` gets the official
51
+ defaults and an invalid value is rejected exactly as the official backend
52
+ rejects it. `@deepseek-ai/dsh-pwsh-local`, which the pwsh dialect default
53
+ resolves through, is declared as a peer dependency.
54
+ - A mounted home registry is the only source of the injected home facts: a
55
+ failing `ctx.shellEnv.collect()` fails the spawn instead of silently falling
56
+ back to the ambient `DSH_HOME`, and a fact the registry does not declare is
57
+ left undeclared rather than borrowed from another source. The public
58
+ `dsh-home-paths` / `profileContext` fallback applies only when no registry is
59
+ mounted at all.
60
+
61
+ ### Documentation
62
+
63
+ - README (both languages): support matrix, security model, expanded Known
64
+ Limitations and a troubleshooting section (`ERR_MODULE_NOT_FOUND`,
65
+ `ERR_PNPM_NO_OFFLINE_TARBALL`).
66
+ - `THIRD-PARTY-NOTICES.md`: origin, version and license of every package
67
+ bundled into the tarball.
68
+
69
+ ### Unchanged
70
+
71
+ - The legacy one-shot surface keeps its exact code path: `index.js`, `tool.js`,
72
+ `persistent.js` and `terminal-fish.js` are untouched, and so are the
73
+ host-plane patch rows (`bash-sandbox`/`tool-bash` disabled;
74
+ `fish-shell`/`tool-fish`/`fish-preset-policy` inserted).
package/README.md CHANGED
@@ -3,10 +3,15 @@
3
3
  **English** | [中文](./README.zh.md)
4
4
 
5
5
  Fish shell executors and tool for DeepSeek Harness: run commands with
6
- **fish** instead of bash. Distribution-ready and surface-agnostic: works in
7
- any profile that mounts it — preset-roster based (dsh-tui, web) or
8
- host-tool based (headless) — and under **any agent preset** (standard, ptc,
9
- cordis, minimal, or a third-party preset).
6
+ **fish** instead of bash. The bundle is surface-agnostic by construction —
7
+ preset-roster based (dsh-tui, web) or host-tool based (headless) — and the
8
+ per-agent policy is written to apply under any agent preset (standard, ptc,
9
+ cordis, minimal, or a third-party preset). The **verified envelope is narrower
10
+ than the design**: the real lanes cover `dsh-base`-class profiles (an isolated
11
+ profile whose bundle layers are `@deepseek-ai/dsh-base` + this bundle), the
12
+ `dsh-tui` composition, and the official `minimal` preset. Every other
13
+ preset/profile combination is **unverified**, so read **Known Limitations**
14
+ below for the authoritative scope before deploying elsewhere.
10
15
 
11
16
  ## What this bundle does
12
17
 
@@ -101,6 +106,40 @@ cordis, minimal, or a third-party preset).
101
106
  realm). Command quoting uses fish single-quote escaping and `eval` with
102
107
  one string argument (fish 4 has no `$'...'` ANSI-C quoting and no
103
108
  `eval --`), verified against real fish 4.0.0.
109
+ - **Interactive terminal sessions** (`terminal-tools.js`, exported as
110
+ `@banbolee/dsh-fish-shell/terminal-tools`): the `terminalTools: 'allow' |
111
+ 'deny'` switch for the interactive (L2) surface, whose patch group mounts the
112
+ official `@deepseek-ai/dsh-terminal` registry, this bundle's `terminal.js`
113
+ (the official `@deepseek-ai/dsh-terminal-bash` backend driven with fish argv,
114
+ plus the harness home contract in the PTY child environment), and the
115
+ official `@deepseek-ai/dsh-tool-terminal` (six `terminal_*` tools). See
116
+ **Interactive terminal sessions (L2)** below.
117
+
118
+ ## Support matrix
119
+
120
+ | Item | Supported | Verified |
121
+ |---|---|---|
122
+ | `@deepseek-ai/*` dsh family | `0.2.0-rc.1` — the exact pins live in the repository root `package.json` (`pnpm.overrides`) | yes: real isolated profiles in the S0 probe runs |
123
+ | `fish` | `3.7+` | yes on `3.7.1`; lower versions are unverified |
124
+ | Platform | POSIX only: Linux and macOS. Windows is not supported (the `pwsh` family covers it) | Linux: the real lane with `DSH_REAL_FISH_PTY=1` in the `test` job; macOS: the `fish-pty-macos` job runs the real L2 lane, including the `workspace-write` denial outside the workspace |
125
+ | Node.js | `>=22` (`engines.node`) | yes |
126
+
127
+ ## Test coverage matrix
128
+
129
+ The promises above are only as good as the lanes that assert them. These are
130
+ the lanes, what they boot, and what they cover:
131
+
132
+ | Lane | Boots | Covers |
133
+ |---|---|---|
134
+ | `plugins/fish-shell/tests/terminal-session-real.spec.ts` | a real isolated `dsh-base` profile, through the `dsh` CLI | six tools; `open`/`send`/`read`/`close`; `terminal_signal`; a background `terminal_send` job with `job_output`/`job_kill`; the mode-switch fence; the `workspace-write` denial outside the workspace; the `read-only` semantics; owner isolation (`FOREIGN_SESSION`); the harness home contract (the session's `$DSH_HOME` is the profile's own home, a nested `dsh --profile <profile> --dump-config` composes that profile, and the same command under a foreign home fails) |
135
+ | `tests/composition/fish-pty-dsh-tui.spec.ts` | a real `dsh-tui` composition | the bundle group activates (no pending L2 rows); six tools visible; `open`/`send`/`read`/`close` |
136
+ | `tests/composition/fish-pty-minimal.spec.ts` | the official `minimal` preset over `dsh-base` | two `terminals` realms (host fish group + preset `/bin/bash` persistent shell); six tools exactly once; the persistent-fish swap; `open`/`send`/`read`/`close` |
137
+ | `tests/composition/fish-pty-bundled-row.spec.ts` | a profile patch that references `@deepseek-ai/dsh-tool-terminal` directly | resolution from inside this bundle only; the row stays `pending (waiting for service: terminals)`; no second registration; the surviving registration still drives a PTY |
138
+ | `scripts/qa/fish-packed-offline.mjs` | a packed tarball installed into a temporary profile with `--offline` | the install succeeds and really loads: six tools visible, `listBackends()` non-empty |
139
+
140
+ CI runs the real lane in the `test` job (Linux) and in `fish-pty-macos`
141
+ (macOS), the composition lanes in the `composition` job, and the packed/offline
142
+ lane in `fish-packed-offline`.
104
143
 
105
144
  ## Install
106
145
 
@@ -116,13 +155,18 @@ From this checkout, per profile:
116
155
  dsh plugin --profile <name> add ./plugins/fish-shell
117
156
  ```
118
157
 
119
- Add it to every profile that should run fish (dsh-tui, web, headless, …).
120
- The bundle patch (`cordis.patch.yml`) is one safe patch across surfaces: it
121
- disables the host bash executor and `tool-bash` rows, mounts the fish
122
- executor and the host-global `fish` tool, and inserts the
123
- `fish-preset-policy` row, which hides the preset-inherited `bash` tool per
124
- agent (restriction + empty `tool:bash` prompt shadow, persistent-fish swap
125
- under `minimal`). The patch no longer touches the preset roster.
158
+ Add it to every profile that should run fish (dsh-tui, web, headless, …) —
159
+ remembering the envelope above: `dsh-base`-class profiles, the `dsh-tui`
160
+ composition and the official `minimal` preset are verified today, every other
161
+ preset/profile combination is unverified (see **Known Limitations**). The
162
+ bundle patch
163
+ (`cordis.patch.yml`) is one safe patch across surfaces: it disables the host
164
+ bash executor and `tool-bash` rows, mounts the fish executor and the
165
+ host-global `fish` tool, and inserts the `fish-preset-policy` row, which hides
166
+ the preset-inherited `bash` tool per agent (restriction + empty `tool:bash`
167
+ prompt shadow, persistent-fish swap under `minimal`). It also appends the
168
+ `fish-terminal-group` for interactive terminal sessions (see **Interactive
169
+ terminal sessions (L2)** below). The patch no longer touches the preset roster.
126
170
 
127
171
  ### Deployed copy and the symlink chain
128
172
 
@@ -132,11 +176,138 @@ runtime instance the harness uses — the launcher-maintained
132
176
  `profiles/node_modules/@deepseek-ai/*` symlink chain. A plain `link:` to this
133
177
  checkout (outside the profile tree) would resolve no `@deepseek-ai` package,
134
178
  so the deployed copy lives at `profiles/node_modules/@banbolee/dsh-fish-shell` (inside
135
- the tree). `scripts/sync-to-profile.sh` copies the plugin there after edits.
179
+ the tree). `scripts/sync-to-profile.sh <profile>` packs the plugin with the hoisted
180
+ linker (`pnpm install --prod --config.node-linker=hoisted` then
181
+ `pnpm pack --config.node-linker=hoisted`) and installs the resulting tarball
182
+ into the profile with
183
+ `dsh plugin --profile <profile> add -w <tgz> --offline --config.auto-install-peers=false`.
136
184
  A pnpm `file:` dependency points each profile at the deployed copy. A
137
185
  package published to npm installs normally (its realpath already lies inside
138
186
  the profile tree).
139
187
 
188
+ ### Release and provenance
189
+
190
+ Releases are expected to ship with **npm provenance**: `publishConfig` carries
191
+ `provenance: true`, so publishing works from an OIDC-capable CI (GitHub
192
+ Actions), or with an explicit
193
+ `npm publish --provenance --access public`. A local publish has no OIDC
194
+ provider to attest the build, so a maintainer releasing from a workstation
195
+ has to turn provenance off for that one run with `--no-provenance`. **2FA is a
196
+ release prerequisite** — provenance does not replace it and npm checks it on
197
+ the account/organization side, which this repository cannot verify. Before
198
+ publishing, turn the `[Unreleased]` section of `CHANGELOG.md` into the version
199
+ being released (this `0.8.0` cut did exactly that).
200
+
201
+ ## Interactive terminal sessions (L2)
202
+
203
+ Next to the one-shot `fish` tool, the bundle mounts an interactive terminal
204
+ surface: a real fish PTY the model drives with `terminal_open`,
205
+ `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close` and
206
+ `terminal_list`. It reuses the official terminal stack: the backend row mounts
207
+ `terminal.js`, a subclass of the official `BashTerminalBackend` whose only
208
+ difference is the harness home contract in the child environment (**The harness
209
+ home contract** below). `spawn()` is inherited, so argv confinement, the
210
+ sandbox-mode fence and `session.initialize()` readiness are the official
211
+ code.
212
+
213
+ The patch appends one `insert` group, `fish-terminal-group`, at the end of
214
+ `cordis.patch.yml`:
215
+
216
+ | Row id | Package | Config |
217
+ |---|---|---|
218
+ | `pty` | `@deepseek-ai/dsh-terminal` | — |
219
+ | `terminal-fish-pty` | `@banbolee/dsh-fish-shell/terminal` | `shellPath: fish`, `shellArgs: ["--no-config","-i","-C", <FISH_PROMPT_SETUP>]`, `timeoutMs: 300000` |
220
+ | `terminal-tools` | `@deepseek-ai/dsh-tool-terminal` | — |
221
+ | `fish-terminal-tools` | `@banbolee/dsh-fish-shell/terminal-tools` | `terminalTools: allow` |
222
+
223
+ The group row is `cordis:group` with `group: true` and
224
+ `isolate: { terminals: true }`: the `terminals` service has to be self-contained
225
+ inside the group, because a host-plane row would stay pending in every profile
226
+ whose host plane has no `terminals` service. `tools` is deliberately **not**
227
+ isolated, so the six `terminal_*` tools registered by `terminal-tools` land in
228
+ the host tool registry and are visible to agents.
229
+
230
+ The prompt setup travels in the `-C` argument (the same `FISH_PROMPT_SETUP`
231
+ string that `terminal-fish.js` exports): startup submits nothing, so the motd
232
+ stays clean, whereas feeding the same setup through PTY input gets it echoed
233
+ back by the fish line editor.
234
+
235
+ **The harness home contract.** `terminal.js` exists for exactly one reason: a
236
+ PTY child's environment is the backend overlay plus `scrubbedParentEnv()`,
237
+ which drops every inherited `DSH_*` name, so a `dsh` nested inside an
238
+ interactive session used to resolve the default `~/.dsh` while the one-shot
239
+ `fish` tool ran on the real harness home. The subclass adds `DSH_HOME` — and
240
+ `DSH_PROFILE` / `DSH_PROFILE_DIR` when the context carries them — from the SAME
241
+ registry the one-shot tool reads, `ctx.shellEnv.collect()`, through the official
242
+ constructor's public `spawnTerminal` seam (the layer the subprocess provider
243
+ applies after the ambient scrub). The snapshot is taken at spawn time because
244
+ the three home facts are execution-independent: `collect` only reads the
245
+ execution to add `DSH_SESSION_ID`, so the session facts (`DSH_SHELL`,
246
+ `DSH_SESSION_ID`, `DSH_PTY_SESSION_ID`) stay the official overlay's own values.
247
+ Nothing else differs: the config surface is the official schema re-exported
248
+ verbatim (`Config`), so `backendType` keeps its `shell` default, which is what
249
+ `listBackends()` reports and `terminal_open { type: 'shell' }` opens. The
250
+ overlay has exactly ONE source per mounted state: when the registry is not
251
+ mounted at all (a custom composition outside `dsh-base`) the same facts come
252
+ from their public owners — `resolveDshHome()` and `ctx.get('profileContext')`,
253
+ which are the registry's own sources — while a mounted registry is
254
+ authoritative, so a `collect` error propagates (like the one-shot path) instead
255
+ of silently resolving another home, and a key its snapshot does not declare
256
+ stays undeclared instead of being borrowed from the ambient environment.
257
+ **Maintenance note:** the row is ours only
258
+ because the official row exposes no `env` config field; if
259
+ `@deepseek-ai/dsh-terminal-bash` grows one, the row goes back to the official
260
+ package and `terminal.js` is deleted.
261
+
262
+ `fish-terminal-tools` is the only entry this plugin adds for the surface: the
263
+ `terminalTools` switch implemented in `terminal-tools.js`. Its default is
264
+ `allow` (do nothing, matching every other tool in the deployment); `deny` takes
265
+ the six tools away per agent — see **Security model**.
266
+
267
+ **Why the one-shot path is untouched.** The appended group is purely additive.
268
+ The host-plane rows above it stay byte-identical (`bash-sandbox`/`tool-bash`
269
+ disabled, `fish-shell`/`tool-fish`/`fish-preset-policy` inserted), and
270
+ `ctx.shell` (`index.js`), the one-shot `fish` tool (`tool.js`), the `minimal`
271
+ preset's persistent tool (`persistent.js`) and the `terminal-fish.js` library
272
+ keep their exact code paths: nothing about a one-shot command changes because
273
+ an interactive surface exists.
274
+
275
+ **The bundled `@deepseek-ai/dsh-tool-terminal`.** The `terminal-tools` row
276
+ resolves the official tool package from inside this package, not from the
277
+ profile plane: `@deepseek-ai/dsh-tool-terminal@0.2.0-rc.1` is declared in
278
+ `dependencies` and `bundledDependencies`, so packing ships it, and its
279
+ transitive dependencies, as real files under `node_modules/` inside the
280
+ tarball. That is what makes an offline profile install work. It must not be
281
+ moved to `peerDependencies`: peer resolution would look for a profile-plane
282
+ copy, which is exactly what this design avoids. Versions, origins and licenses
283
+ are listed in `./THIRD-PARTY-NOTICES.md`.
284
+
285
+ ## Security model
286
+
287
+ - **Default: `terminalTools: 'allow'`.** The interactive surface is on by
288
+ default, exactly like every other tool the deployment mounts; a fish profile
289
+ with this bundle installed behaves like any other profile that has the
290
+ official terminal tools. `deny` is the opt-out: set the
291
+ `fish-terminal-tools` row's config to `terminalTools: deny`, and at
292
+ `agent/created`, and again on every `tools/change`, the policy restricts the
293
+ six names per agent through the official
294
+ `agent.ctx.tools.restrict({ deny: [...] })`. The names are
295
+ enumerated one by one (never a `terminal_*` wildcard), so an upstream seventh
296
+ tool cannot silently pass the policy.
297
+ - **The six tools**: `terminal_open`, `terminal_send`, `terminal_read`,
298
+ `terminal_signal`, `terminal_close`, `terminal_list`.
299
+ - **No privilege escalation.** This plugin registers no tool of its own, wraps
300
+ or replaces no host object, monkey-patches nothing, and offers no
301
+ sudo/elevation entry point. A session process is `fish` running with the
302
+ permissions of the user that launched the profile.
303
+ - **Sandbox inheritance.** Confinement is the host's: a `workspace-write`
304
+ profile fences the interactive session through the same sandbox backend as
305
+ any other command (measured: a write to `/etc` is denied). The plugin does
306
+ not bypass the fence and caches nothing outside the sandbox.
307
+ - **Secret handling and audit are bounded** — **Known Limitations** below
308
+ states the hidden-input boundary, the unsupported interaction surface and the
309
+ best-effort audit scope.
310
+
140
311
  ## Per-surface behavior
141
312
 
142
313
  - **Preset-roster surfaces** (dsh-tui, web): the roster default is untouched
@@ -180,7 +351,11 @@ the profile tree).
180
351
  `start()` and the official `dsh-tool-bash`): it runs until `job_kill`,
181
352
  cancellation, or composition teardown. Requires the jobs services
182
353
  (`@deepseek-ai/dsh-jobs` + `@deepseek-ai/dsh-tool-jobs`) to be composed;
183
- the tool fails loud when they are not.
354
+ the tool fails loud when they are not. Killing a job while it is still
355
+ running is verified by the real PTY lane
356
+ (`plugins/fish-shell/tests/terminal-session-real.spec.ts`): `job_output`
357
+ reads a live `terminal_send` job and `job_kill` settles it to the terminal
358
+ `killed` status.
184
359
  - Under a sandboxing executor, a denied command can be re-run wider in the
185
360
  same turn with `sandbox_permissions` (the narrowest wider mode that
186
361
  suffices) plus a one-sentence `justification`; the approval prompt raised
@@ -216,5 +391,127 @@ the profile tree).
216
391
  escalation (`sandbox_permissions`/`justification` via `ctx.approval`),
217
392
  terminal/generic UI presentation, and the `tool:fish` exit-code prompt
218
393
  section are all supported.
394
+ - **Secrets: only true hidden input is hidden.** A command line, an argument or
395
+ a `terminal_send` payload is public to the session: the fish line editor
396
+ echoes what it receives and the session keeps the scrollback, so anything
397
+ that entered non-hidden can be read back by `terminal_read`. Never pass a
398
+ secret as a command argument or through `terminal_send`; a real hidden read
399
+ (`read -s` in fish) is the only supported way to keep text out of the echoed
400
+ stream. `stty -echo` is **not** a mitigation: fish's line editor repaints and
401
+ the sentinel stays in the scrollback (measured).
402
+ - **Audit is best-effort, not a lifecycle guarantee.** The plugin observes only
403
+ what the model's explicit `terminal_open`/`terminal_close` calls expose
404
+ (session id, PID, status transitions). It does **not** cover host-side
405
+ reclamation, timeout kills, a process exiting on its own, other agents' or
406
+ owners' sessions, or the group's `terminals` realm internals (invisible at
407
+ the host plane). There is no full-lifecycle promise: the official
408
+ `TerminalSessionService` exposes no open/exit/dispose events, and the plugin
409
+ does not invent an audit surface of its own.
410
+ - **Text interaction only.** `resize`, named keys (arrow keys, named Ctrl
411
+ combinations), full-screen TUIs (`vim`, `htop`) and `TERM=dumb`-style
412
+ degraded terminals are not supported by the official session API; supporting
413
+ them would require a plugin-owned backend, which is out of scope.
414
+ - **Verified L2 behaviors.** Each of these was an open question when the
415
+ interactive surface was first documented; each now has a lane that asserts the
416
+ recorded behavior (the lane index is **Test coverage matrix**):
417
+ - **Preset compositions.** Two `terminals` realms coexist under the official
418
+ `minimal` preset — the host L2 group's (fish) and the preset's own
419
+ persistent-shell group's (`/bin/bash`) — and each of the six `terminal_*`
420
+ tools is visible to the preset-bound agent exactly once; the `dsh-tui`
421
+ composition mounts the same group and drives `open`/`send`/`read`/`close`
422
+ (`tests/composition/fish-pty-minimal.spec.ts`,
423
+ `tests/composition/fish-pty-dsh-tui.spec.ts`).
424
+ - **Mode switch with a live session.** The standing-policy switch is refused
425
+ synchronously with `cannot change sandbox mode … while persistent terminal
426
+ sessions are open`: the mode event is not committed, the effective mode is
427
+ unchanged, the live session keeps working, and the same switch succeeds once
428
+ the session is closed
429
+ (`plugins/fish-shell/tests/terminal-session-real.spec.ts`).
430
+ - **`read-only` mode.** `read-only` is a file-effect mode, not a capability
431
+ gate: the six tools stay visible and the PTY still spawns (motd `dsh>`), but
432
+ the resolved policy has no writable root at all, so even a write inside the
433
+ session workspace is denied — the workspace is not a write boundary under
434
+ `read-only` (same lane).
435
+ - **Cross-agent owner isolation.** A second agent in the same profile sees an
436
+ empty `terminal_list` and cannot read, write or close the first agent's
437
+ session: the tool error is `belongs to another agent` and the registry code
438
+ behind it is `FOREIGN_SESSION`, while the owner's session survives untouched
439
+ (same lane).
440
+ - **A profile patch referencing the bundled package directly.** The name
441
+ resolves from inside this bundle only
442
+ (`…/@banbolee/dsh-fish-shell/node_modules/@deepseek-ai/dsh-tool-terminal`),
443
+ never from the dsh installation; resolution is not activation — because the
444
+ group isolates `terminals`, that row stays
445
+ `pending (waiting for service: terminals)`, so the six tools are never
446
+ registered twice, and the boot reports the pending row on stderr instead of
447
+ swallowing it (`tests/composition/fish-pty-bundled-row.spec.ts`).
448
+ - **Unverified (do not read as supported)**:
449
+ - the `FishTerminalBackend` submitted-setup path converging on fish 3.7.1:
450
+ that library is not mounted by the patch, so the L2 surface is unaffected,
451
+ and the legacy persistent path keeps its own specs;
452
+ - a first install in a fully offline, cold-store environment: the packed lane
453
+ (`scripts/qa/fish-packed-offline.mjs`) installs with `--offline`, but its
454
+ store was warmed by the repository install first.
219
455
  - POSIX only: the `fish` binary and the underlying process-group semantics
220
456
  are not available on Windows (the pwsh family covers Windows).
457
+
458
+ ## Troubleshooting
459
+
460
+ ### `ERR_MODULE_NOT_FOUND` for `@deepseek-ai/dsh-tool-terminal`
461
+
462
+ The bundled tool package did not make it into the installed package, or it was
463
+ resolved as a peer (profile-plane) dependency. Build with the hoisted linker:
464
+ `pnpm install --prod --config.node-linker=hoisted` and then
465
+ `pnpm pack --config.node-linker=hoisted`. Without it pnpm refuses to pack a
466
+ bundled dependency and fails with
467
+ `ERR_PNPM_BUNDLED_DEPENDENCIES_WITHOUT_HOISTED`. Check the artefact: the
468
+ tarball must contain
469
+ `package/node_modules/@deepseek-ai/dsh-tool-terminal/package.json`. One
470
+ upstream cause is not ours to fix: `@deepseek-ai/dsh-tool-terminal@0.2.0-rc.1`
471
+ declares `exports` entries for `./src/*` that the published tarball does not
472
+ contain, so anything resolving through that subpath fails with
473
+ `ERR_MODULE_NOT_FOUND`.
474
+
475
+ ### `ERR_PNPM_NO_OFFLINE_TARBALL`
476
+
477
+ The offline install ran against a cold pnpm store: with `--offline`, pnpm
478
+ refuses to go to the network for a tarball it has not cached. Warm the store
479
+ once with a networked install (or run the same `dsh plugin ... add` without
480
+ `--offline` for the first install), then re-run the offline command. The
481
+ plugin's own runtime dependency travels inside the plugin tarball, so this is a
482
+ store-state problem, not a missing bundled file.
483
+
484
+ ### Nested `dsh` inside an interactive session
485
+
486
+ As of this bundle the session carries the harness home contract: its child
487
+ environment gets `DSH_HOME` (plus `DSH_PROFILE` / `DSH_PROFILE_DIR` when the
488
+ profile context has them) from the same `ctx.shellEnv` registry the one-shot
489
+ `fish` tool reads, so the two surfaces cannot drift — **The harness home
490
+ contract** above says where and why. The real lane asserts the outcome: inside a
491
+ session `echo $DSH_HOME` prints the profile's own home, and
492
+ `dsh --profile <profile> --dump-config` composes that profile's tree, while the
493
+ same command under a foreign `DSH_HOME` fails. A nested `dsh` therefore starts
494
+ on the same home and the same plugin set as the session it was opened in.
495
+
496
+ Older bundles — and any deployment whose `terminal-fish-pty` row is still the
497
+ official `@deepseek-ai/dsh-terminal-bash` — have no such contract, and there was
498
+ no config-only fix for them: that row exposes no `env` config field, so an
499
+ `env:` written on it was accepted by the schema and silently ignored, and the
500
+ backend's `childEnvironment()` is module-private. A nested `dsh` there resolved
501
+ `~/.dsh`, which can hold profile and plugin state from an older release — a
502
+ stale plugin there can fail the nested boot or disable its rows. Pass the home
503
+ explicitly — replace `/path/to/dsh-home` with the Harness home the outer session
504
+ was launched with (a one-shot `fish` call prints it: `echo $DSH_HOME`):
505
+
506
+ ```
507
+ env -u DSH_SESSION_ID -u DSH_PTY_SESSION_ID -u DSH_SHELL -u DSH_PERMISSION_MODE DSH_HOME=/path/to/dsh-home dsh --profile <profile>
508
+ ```
509
+
510
+ **Maintenance note.** Owning the row (`terminal.js`) is the only reason the
511
+ contract can be added without editing the official package; if
512
+ `@deepseek-ai/dsh-terminal-bash` grows an `env` config field (or the registry
513
+ forwards the home facts itself), the row goes back to the official package,
514
+ `terminal.js` is deleted, and only the first paragraph of this entry remains —
515
+ the real lane keeps its assertions either way. That retirement also has to
516
+ update the `NESTED_DSH_ENTRY` contract in
517
+ `tests/docs-shape-fish-shell.spec.ts`, which names this module.
package/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](./README.md) | **中文**
4
4
 
5
- DeepSeek Harness 的 fish shell executor 与工具:用 **fish** 而不是 bash 运行命令。可直接分发、与 surface 无关:任何挂载它的 profile 都能用——基于 preset roster 的(dsh-tui、web)或基于 host tool 的(headless)——并且在**任意 agent preset**(standard、ptc、cordis、minimal 或第三方 preset)下都能用。
5
+ DeepSeek Harness 的 fish shell executor 与工具:用 **fish** 而不是 bash 运行命令。bundle 在设计上与 surface 无关——基于 preset roster 的(dsh-tui、web)或基于 host tool 的(headless)——per-agent policy 也按「任意 agent preset 都适用」编写(standard、ptc、cordis、minimal 或第三方 preset)。但**已验证范围比设计更窄**:真实 lane 覆盖的是 `dsh-base` 类 profile(隔离 profile 的 bundle layer 为 `@deepseek-ai/dsh-base` + 本 bundle)、`dsh-tui` 组合与官方 `minimal` preset。其它 preset/profile 组合一律**未验证**,部署到别处之前请以下方 **已知限制** 为权威口径。
6
6
 
7
7
  ## 这个 bundle 做什么
8
8
 
@@ -18,6 +18,30 @@ DeepSeek Harness 的 fish shell executor 与工具:用 **fish** 而不是 bash
18
18
  - **Per-agent fish policy**(`policy.js`,导出为 `@banbolee/dsh-fish-shell/policy`):一个以 host 方式挂载的 Cordis plugin,让每个 agent 都看到 fish 而不是 bash,**无论它跑在哪个 agent preset 下**。bundle patch 会禁用基础 bash executor(`bash-sandbox`)与 host bash 工具(`tool-bash`),挂载 fish executor 与 host-global `fish` 工具,并且不改动 preset——它不再修改 roster 默认值、不再注入 preset root,也不再往 `$DSH_HOME/.agent-presets/` 下写副本。preset 仍会注册自己的 `bash` 同名工具(standard/minimal 就会),而在 `agent/created` 时(以及每次 `tools/change`,即 `/preset` 重新组合之后),policy 会通过 `agent.ctx.tools.restrict({ deny: ['bash'] })` 按 agent 隐藏这个继承来的 bash 工具,并用一个空的 agent 作用域小节遮蔽 preset 静态的 `tool:bash` 提示词指引。agent 最终只剩下唯一一个 shell 工具:`fish`。在 `minimal` 下——它常驻的 bash 是持久 PTY 形态(参数只带 `command`,输出 schema 是普通字符串)——policy 会先在 agent 作用域注册一个持久 `fish` 工具(遮蔽 one-shot fish),然后隐藏 bash,因此 minimal session 在 fish 下保持持久语义。
19
19
  - **持久 fish terminal 后端**(`terminal-fish.js`,导出为 `@banbolee/dsh-fish-shell/terminal-fish`):一个库——**不**由 bundle patch 挂载——提供持久工具使用的 `fish` PTY 后端。它继承官方 `BashTerminalBackend`,启动 `fish --no-config -i`(可通过配置覆盖)并配上受控提示符——该提示符遵循 harness readiness 契约(OSC `133;D;<status>` + `dsh> `)——并通过同一套 sandbox policy 施加限制;官方 sandbox-mode fence 被复制进来(官方模块没有导出它;这份副本为自管理用途提供了一个可注入的 owner-activity 检查)。patch 刻意没有 `fish-terminal` host row:`terminals` service 是 `minimal` preset isolate realm 的 entry-local 资源,在 host 平面(headless / dsh-tui / composition 测试 profile)不可见,因此这样一行会永远停留在 pending。`apply` 会被导出,供直接装配与测试使用(或供显式挂载该 registry 的 composition 使用)。
20
20
  - **持久 fish 工具**(`persistent.js`,导出为 `@banbolee/dsh-fish-shell/persistent`):官方 `dsh-tool-bash-persistent` 模式的 fish 版本——每个 agent 一个缓存的 PTY shell,其 cwd、变量与函数在多次调用之间存活,并支持 marker 包裹的命令、串行执行、deadline/timeout 重置与 scrollback 读取。它刻意不导出 Cordis `apply`:`policy.js` 会在 agent 边界为 persistent-bash preset 调用 `registerPersistentFish(ctx, agentCtx)`。PTY 是**自管理**的:工具自己持有一个 `FishTerminalBackend` 实例并直接驱动它的 session(`backend.spawn(...)`、`session.startSend/read/status/close`),而不经过 `terminals` registry——agent 作用域的工具解析不到它(该 registry 只存在于 minimal preset 的 entry-local realm 内)。命令的引号处理使用 fish 单引号转义,并用带单个字符串参数的 `eval`(fish 4 没有 `$'...'` ANSI-C 引号语法,也没有 `eval --`),已用真实 fish 4.0.0 验证。
21
+ - **交互式终端会话**(`terminal-tools.js`,导出为 `@banbolee/dsh-fish-shell/terminal-tools`):交互式(L2)surface 的 `terminalTools: 'allow' | 'deny'` 开关;它的 patch group 挂载官方 `@deepseek-ai/dsh-terminal` registry、本 bundle 的 `terminal.js`(用 fish argv 驱动的官方 `@deepseek-ai/dsh-terminal-bash` 后端,外加 PTY 子进程环境里的 harness home 契约),以及官方 `@deepseek-ai/dsh-tool-terminal`(六个 `terminal_*` 工具)。见下方 **交互式终端会话(L2)**。
22
+
23
+ ## 支持矩阵
24
+
25
+ | 项 | 支持范围 | 验证情况 |
26
+ |---|---|---|
27
+ | `@deepseek-ai/*` dsh 族 | `0.2.0-rc.1` —— 精确版本钉在仓库根 `package.json`(`pnpm.overrides`) | 是:S0 探针在真实隔离 profile 里跑过 |
28
+ | `fish` | `3.7+` | 是,实测 `3.7.1`;更低版本未验证 |
29
+ | 平台 | 仅 POSIX:Linux 与 macOS。Windows 不支持(Windows 由 `pwsh` 系列覆盖) | Linux:`test` job 里 `DSH_REAL_FISH_PTY=1` 的真实 lane;macOS:`fish-pty-macos` job 跑真实 L2 lane,含 `workspace-write` 下 workspace 外写入被拒 |
30
+ | Node.js | `>=22`(`engines.node`) | 是 |
31
+
32
+ ## 测试覆盖矩阵
33
+
34
+ 上面的承诺,只由真正断言它的 lane 兜底。下表列出每条 lane、它启动什么、覆盖什么:
35
+
36
+ | Lane | 启动 | 覆盖 |
37
+ |---|---|---|
38
+ | `plugins/fish-shell/tests/terminal-session-real.spec.ts` | 经 `dsh` CLI 启动的真实隔离 `dsh-base` profile | 六个工具;`open`/`send`/`read`/`close`;`terminal_signal`;后台 `terminal_send` job 与 `job_output`/`job_kill`;切 mode 的 fence;`workspace-write` 下 workspace 外写入被拒;`read-only` 语义;owner 隔离(`FOREIGN_SESSION`);harness home 契约(会话内 `$DSH_HOME` 就是本 profile 的 home,嵌套 `dsh --profile <profile> --dump-config` 组合出的正是该 profile,而同一个命令换到陌生 home 下会失败) |
39
+ | `tests/composition/fish-pty-dsh-tui.spec.ts` | 真实的 `dsh-tui` 组合 | bundle group 激活(没有 pending 的 L2 row);六个工具可见;`open`/`send`/`read`/`close` |
40
+ | `tests/composition/fish-pty-minimal.spec.ts` | `dsh-base` + 官方 `minimal` preset | 两个 `terminals` realm(host fish group + preset 的 `/bin/bash` 持久 shell);六个工具各恰好一次;持久 fish 替换;`open`/`send`/`read`/`close` |
41
+ | `tests/composition/fish-pty-bundled-row.spec.ts` | 直引 `@deepseek-ai/dsh-tool-terminal` 的 profile patch | 只从本 bundle 内部解析;该 row 停在 `pending (waiting for service: terminals)`;不会重复注册;存活的那份注册仍能驱动 PTY |
42
+ | `scripts/qa/fish-packed-offline.mjs` | 用 `--offline` 装进临时 profile 的已打包 tarball | 安装成功且真的加载:六个工具可见、`listBackends()` 非空 |
43
+
44
+ CI 里:真实 lane 由 `test` job(Linux)与 `fish-pty-macos`(macOS)跑,composition lane 由 `composition` job 跑,打包/离线 lane 由 `fish-packed-offline` job 跑。
21
45
 
22
46
  ## 安装
23
47
 
@@ -33,11 +57,48 @@ dsh plugin --profile <name> add @banbolee/dsh-fish-shell
33
57
  dsh plugin --profile <name> add ./plugins/fish-shell
34
58
  ```
35
59
 
36
- 把它加到每一个需要运行 fish 的 profile(dsh-tui、web、headless……)。bundle patch(`cordis.patch.yml`)是一个跨 surface 安全的 patch:它禁用 host bash executor 与 `tool-bash` row,挂载 fish executor 与 host-global `fish` 工具,并插入 `fish-preset-policy` row——该 row 会按 agent 隐藏 preset 继承来的 `bash` 工具(restriction + 空的 `tool:bash` 提示词遮蔽,在 `minimal` 下换成 persistent fish)。patch 不再改动 preset roster。
60
+ 把它加到每一个需要运行 fish 的 profile(dsh-tui、web、headless……)——但要记住上面的口径:`dsh-base` 类 profile、`dsh-tui` 组合与官方 `minimal` preset 已验证,其它 preset/profile 组合都未验证(见 **已知限制**)。bundle patch(`cordis.patch.yml`)是一个跨 surface 安全的 patch:它禁用 host bash executor 与 `tool-bash` row,挂载 fish executor 与 host-global `fish` 工具,并插入 `fish-preset-policy` row——该 row 会按 agent 隐藏 preset 继承来的 `bash` 工具(restriction + 空的 `tool:bash` 提示词遮蔽,在 `minimal` 下换成 persistent fish)。它还会为交互式终端会话追加 `fish-terminal-group`(见下方 **交互式终端会话(L2)**)。patch 不再改动 preset roster。
37
61
 
38
62
  ### 部署副本与 symlink 链
39
63
 
40
- 这些 executor 继承 `@deepseek-ai/dsh-bash-local` / `@deepseek-ai/dsh-bash-sandbox`。这些 import 必须解析到 harness 使用的同一个运行时实例——即 launcher 维护的 `profiles/node_modules/@deepseek-ai/*` symlink 链。直接 `link:` 到本仓库检出(位于 profile 树之外)将解析不到任何 `@deepseek-ai` 包,因此部署副本放在 `profiles/node_modules/@banbolee/dsh-fish-shell`(树内)。`scripts/sync-to-profile.sh` 会在改动后把插件复制到那里。pnpm 的 `file:` 依赖让每个 profile 指向该部署副本。发布到 npm 的包则正常安装(它的 realpath 本来就在 profile 树内)。
64
+ 这些 executor 继承 `@deepseek-ai/dsh-bash-local` / `@deepseek-ai/dsh-bash-sandbox`。这些 import 必须解析到 harness 使用的同一个运行时实例——即 launcher 维护的 `profiles/node_modules/@deepseek-ai/*` symlink 链。直接 `link:` 到本仓库检出(位于 profile 树之外)将解析不到任何 `@deepseek-ai` 包,因此部署副本放在 `profiles/node_modules/@banbolee/dsh-fish-shell`(树内)。`scripts/sync-to-profile.sh <profile>` 会用 hoisted linker 打包插件(先 `pnpm install --prod --config.node-linker=hoisted`,再 `pnpm pack --config.node-linker=hoisted`),并用 `dsh plugin --profile <profile> add -w <tgz> --offline --config.auto-install-peers=false` 把产出的 tarball 装进 profile。pnpm 的 `file:` 依赖让每个 profile 指向该部署副本。发布到 npm 的包则正常安装(它的 realpath 本来就在 profile 树内)。
65
+
66
+ ### 发布与 provenance
67
+
68
+ 发布预期带上 **npm provenance**:`publishConfig` 里带 `provenance: true`,因此从支持 OIDC 的 CI(GitHub Actions)发布即可,或显式执行 `npm publish --provenance --access public`。本机发布会缺少为该次构建作证的 OIDC provider,因此维护者从工作站发版时必须为那一次发布关掉 provenance,用 `--no-provenance`。**2FA 是发版前置条件**——provenance 不能替代它,且 npm 在账户/组织侧校验,本仓库无法验证。发布前把 `CHANGELOG.md` 的 `[Unreleased]` 落成即将发布的版本号(本次 `0.8.0` 就是这样做的)。
69
+
70
+ ## 交互式终端会话(L2)
71
+
72
+ 除了一次性的 `fish` 工具,bundle 还挂载了一个交互式终端 surface:一个真实 fish PTY,模型用 `terminal_open`、`terminal_send`、`terminal_read`、`terminal_signal`、`terminal_close` 与 `terminal_list` 来驱动它。它复用官方终端栈:后端 row 挂载的是 `terminal.js`——官方 `BashTerminalBackend` 的子类,与官方唯一的差别就是子进程环境里的 harness home 契约(见下方 **harness home 契约**);`spawn()` 完全继承,因此 argv 约束、sandbox-mode fence 与 `session.initialize()` 就绪语义都是官方代码。
73
+
74
+ patch 在 `cordis.patch.yml` 末尾追加了一个 `insert` group,id 为 `fish-terminal-group`:
75
+
76
+ | Row id | 包 | 配置 |
77
+ |---|---|---|
78
+ | `pty` | `@deepseek-ai/dsh-terminal` | — |
79
+ | `terminal-fish-pty` | `@banbolee/dsh-fish-shell/terminal` | `shellPath: fish`、`shellArgs: ["--no-config","-i","-C", <FISH_PROMPT_SETUP>]`、`timeoutMs: 300000` |
80
+ | `terminal-tools` | `@deepseek-ai/dsh-tool-terminal` | — |
81
+ | `fish-terminal-tools` | `@banbolee/dsh-fish-shell/terminal-tools` | `terminalTools: allow` |
82
+
83
+ group 行是 `cordis:group`,带 `group: true` 与 `isolate: { terminals: true }`:`terminals` service 必须在 group 内自足,因为挂在 host 平面的 row 会在所有 host 平面没有 `terminals` service 的 profile 里永远 pending。`tools` 刻意**不**隔离,因此 `terminal-tools` 注册的六个 `terminal_*` 工具会进入宿主 tools 注册表、对 agent 可见。
84
+
85
+ prompt setup 走 `-C` 参数(与 `terminal-fish.js` 导出的 `FISH_PROMPT_SETUP` 是同一个字符串):启动时不提交任何输入,所以 motd 保持干净;而把同一份 setup 从 PTY 输入喂进去,会被 fish line editor 回显出来。
86
+
87
+ **harness home 契约。** `terminal.js` 存在的唯一理由:PTY 子进程的环境是后端 overlay 加上 `scrubbedParentEnv()`,而后者会丢掉所有继承来的 `DSH_*` 名,所以过去在交互式会话里嵌套的 `dsh` 会把 home 解析成默认的 `~/.dsh`,而一次性 `fish` 工具用的却是真实的 harness home。该子类通过官方 constructor 的公开 `spawnTerminal` 缝(即 subprocess provider 在 ambient scrub **之后**才应用的那一层)补上 `DSH_HOME`,并在 context 能提供时补上 `DSH_PROFILE` / `DSH_PROFILE_DIR`,取值来自与一次性工具**同一个** registry:`ctx.shellEnv.collect()`。快照可以在 spawn 时取,是因为这三个 home 事实与执行无关——`collect` 只在加 `DSH_SESSION_ID` 时才读 execution——因此 `DSH_SHELL`、`DSH_SESSION_ID`、`DSH_PTY_SESSION_ID` 仍是官方 overlay 自己的值。除此之外没有任何差别:配置面就是原样 re-export 的官方 schema(`Config`),所以 `backendType` 保持默认 `shell`,也就是 `listBackends()` 报出的类型、`terminal_open { type: 'shell' }` 打开的类型。若 registry 完全没挂载(`dsh-base` 之外的自定义组合),同样这三个值会退到它们的公开来源——`resolveDshHome()` 与 `ctx.get('profileContext')`,即 registry 自己用的来源;而挂载着的 registry 就是权威来源:`collect()` 抛错会像一次性路径那样直接传播、而不是悄悄换成另一个 home,它的快照没声明的键也保持不声明、不从 ambient 环境或 profile context 借值。**维护注记:** 这一 row 属于我们,只是因为官方 row 没有 `env` 配置字段;一旦 `@deepseek-ai/dsh-terminal-bash` 支持了它,row 就回到官方包、`terminal.js` 会被删除。
88
+
89
+ `fish-terminal-tools` 是本插件为该 surface 新增的唯一入口:`terminal-tools.js` 里实现的 `terminalTools` 开关。它默认 `allow`(什么都不做,与部署中的其它工具一致);`deny` 会按 agent 收回这六个工具——见 **安全模型**。
90
+
91
+ **为什么一次性路径一行没动。** 追加的 group 是纯增量的。它上面的 host 平面 row 保持逐字不变(`bash-sandbox`/`tool-bash` 禁用,`fish-shell`/`tool-fish`/`fish-preset-policy` 插入),并且 `ctx.shell`(`index.js`)、一次性 `fish` 工具(`tool.js`)、`minimal` preset 的持久工具(`persistent.js`)与 `terminal-fish.js` 库都保持原样的代码路径:交互式 surface 的存在不改变一次性命令的任何行为。
92
+
93
+ **随包分发的 `@deepseek-ai/dsh-tool-terminal`。** `terminal-tools` row 从本包内部解析官方工具包,而不是从 profile 平面解析:`@deepseek-ai/dsh-tool-terminal@0.2.0-rc.1` 声明在 `dependencies` 与 `bundledDependencies` 里,所以打包会把它及其传递依赖作为实体文件放进 tarball 内的 `node_modules/` 下。这正是离线 profile 能安装成功的原因。它**不能**被挪到 `peerDependencies`:peer 解析会去找 profile 平面的副本,而那正是本设计要避免的。版本、来源与许可证见 `./THIRD-PARTY-NOTICES.md`。
94
+
95
+ ## 安全模型
96
+
97
+ - **默认:`terminalTools: 'allow'`。** 交互式 surface 默认开启,与部署中挂载的其它工具完全一样;装了本 bundle 的 fish profile 行为等同于任何带官方终端工具的 profile。`deny` 是退出开关:把 `fish-terminal-tools` row 的 config 设为 `terminalTools: deny`,之后在 `agent/created` 时、以及每次 `tools/change`,policy 都会通过官方 `agent.ctx.tools.restrict({ deny: [...] })` 按 agent 限制这六个名字。名字是逐个枚举的(从不用 `terminal_*` 通配),因此上游新增的第七个工具不会静默穿透策略。
98
+ - **六个工具**:`terminal_open`、`terminal_send`、`terminal_read`、`terminal_signal`、`terminal_close`、`terminal_list`。
99
+ - **不提供任何提权入口。** 本插件不注册自己的工具、不包装或替换任何宿主对象、不做 monkey-patch,也不提供 sudo/提权入口。会话进程就是 `fish`,权限等于启动该 profile 的用户权限。
100
+ - **沙箱继承。** 限制由宿主施加:`workspace-write` profile 会通过与其它命令相同的 sandbox 后端把交互式会话 fence 住(实测:写 `/etc` 被拒)。插件不旁路该 fence,也不把任何东西缓存到沙箱之外。
101
+ - **secret 处置与审计都有边界** —— 下方的 **已知限制** 写明了 hidden-input 边界、不支持的交互面与 best-effort 审计范围。
41
102
 
42
103
  ## 各 surface 的行为
43
104
 
@@ -49,7 +110,7 @@ dsh plugin --profile <name> add ./plugins/fish-shell
49
110
  - **One-shot**(standard、ptc、cordis、第三方 preset 与 headless):每次 shell 调用都会新起一个 non-login shell(对 `FishSandboxExecutor` 而言是在配置的 sandbox 后端下);调用之间不保留任何状态(cwd、变量、函数、历史)。
50
111
  - **Persistent**(`minimal` preset,它挂载了持久 PTY bash 机制):agent 的 `fish` 工具为每个 agent 维持一个存活的 fish session。当前目录、导出的变量与已定义的函数在调用之间保留;通过该工具运行的命令串行执行;超过工具 deadline 的命令会被打断并重置 shell(下一次调用会从 workspace 用一个全新的 shell 开始)。持久 fish PTY 由工具自己通过 `FishTerminalBackend` 启动并驱动(不涉及 `terminals` registry)。
51
112
  - 每条流的输出都受 executor 配置的上限约束;超时会被钳到 executor 的上限;模型看到的是 harness marker 契约。
52
- - `run_in_background: true` 会把长时间运行的命令作为后台 job 启动(`kind: fish`)并立即返回一个 job id;用 `job_output` 读取输出,用 `job_kill` 停止它。后台 job **不设 deadline**(工具以 `onExpiry: 'none'` 解析它,与 0.1.5 的 `start()` 及官方 `dsh-tool-bash` 一致):它会一直运行到 `job_kill`、取消或 composition teardown。需要组合 jobs service(`@deepseek-ai/dsh-jobs` + `@deepseek-ai/dsh-tool-jobs`);未组合时该工具会 fail loud。
113
+ - `run_in_background: true` 会把长时间运行的命令作为后台 job 启动(`kind: fish`)并立即返回一个 job id;用 `job_output` 读取输出,用 `job_kill` 停止它。后台 job **不设 deadline**(工具以 `onExpiry: 'none'` 解析它,与 0.1.5 的 `start()` 及官方 `dsh-tool-bash` 一致):它会一直运行到 `job_kill`、取消或 composition teardown。需要组合 jobs service(`@deepseek-ai/dsh-jobs` + `@deepseek-ai/dsh-tool-jobs`);未组合时该工具会 fail loud。**运行中的 job 被 kill/cancel 已由真实 PTY lane 验证**(`plugins/fish-shell/tests/terminal-session-real.spec.ts`):`job_output` 能读到仍然存活的 `terminal_send` job,`job_kill` 会把它收敛到终态 `killed`。
53
114
  - 在 sandboxing executor 下,被拒绝的命令可以在同一轮内用 `sandbox_permissions`(够用的最窄的更宽模式)加一句 `justification` 重新以更宽权限执行;这次重试弹出的 approval prompt 就是用户表示同意的方式。当 approval prompt 被禁用或升级被拒绝时,拒绝即为最终结果。
54
115
  - 要求 PATH 中有 `fish`;缺失时 executor 会 fail loud。
55
116
 
@@ -60,4 +121,38 @@ dsh plugin --profile <name> add ./plugins/fish-shell
60
121
  - **`minimal` 下的持久语义**由持久 `fish` 工具提供(cwd、导出的变量与已定义的函数在调用之间保留)。与官方持久 bash 一样,超出 deadline 的命令会被打断并重置 shell;agent 作用域的 `fish` 工具遮蔽了 host-global 的 one-shot 工具,因此升级字段(`sandbox_permissions`/`justification`)与后台执行(`run_in_background`)不属于持久 surface。
61
122
  - **第三方 preset 的边界**:policy 隐藏的是从 preset 继承来的 `bash` 同名工具。如果一个 preset 刻意排除全局 `fish` 工具(用 allowlist 把它过滤掉),或用别的名字注册自己的 shell 工具,那么它会被原样保留,只按 agent 给出一次警告——bundle 从不改动第三方 preset。
62
123
  - `fish` 工具面向模型的 surface 与官方 bash 工具对齐:后台执行(通过 `ctx.jobs` 的 `run_in_background`)、sandbox 升级(通过 `ctx.approval` 的 `sandbox_permissions`/`justification`)、终端/通用 UI 呈现,以及 `tool:fish` exit-code 提示词小节,全都支持。
124
+ - **secret:只有真 hidden-input 才是隐藏的。** 命令行、参数或 `terminal_send` 载荷对会话而言都是公开的:fish line editor 会回显收到的内容,会话也保留 scrollback,所以任何以非隐藏方式进入的内容都能被 `terminal_read` 读回。不要把 secret 作为命令参数或通过 `terminal_send` 传递;真正隐藏的读取(fish 里的 `read -s`)是唯一受支持、能让文本不出现在回显流里的方式。`stty -echo` **不是**处置方案:fish 的 line editor 会 repaint,哨兵仍留在 scrollback 里(实测)。
125
+ - **审计是 best-effort,不是生命周期承诺。** 插件只观察模型显式 `terminal_open`/`terminal_close` 调用能暴露的东西(session id、PID、status 迁移)。它**不**覆盖宿主侧回收、超时 kill、进程自己退出、别的 agent 或 owner 的会话,以及 group 内 `terminals` realm 的内部事件(在 host 平面不可见)。这里没有完整生命周期承诺:官方 `TerminalSessionService` 不暴露 open/exit/dispose 事件,插件也不自造审计面。
126
+ - **只支持文本交互。** 官方会话 API 不支持 `resize`、named key(方向键、具名 Ctrl 组合)、全屏 TUI(`vim`、`htop`)与 `TERM=dumb` 类降级终端;要支持它们只能自建 backend,不在范围内。
127
+ - **已验证的 L2 行为。** 下列每一条在交互式 surface 首次成文时都还是未决问题;现在每一条都有 lane 断言其记录行为(lane 索引见 **测试覆盖矩阵**):
128
+ - **preset 组合。** 官方 `minimal` preset 下两个 `terminals` realm 共存——host L2 group 的(fish)与 preset 自己的 persistent-shell group 的(`/bin/bash`)——六个 `terminal_*` 工具对 preset 绑定的 agent 恰好各出现一次;`dsh-tui` 组合挂载同一个 group,并跑通 `open`/`send`/`read`/`close`(`tests/composition/fish-pty-minimal.spec.ts`、`tests/composition/fish-pty-dsh-tui.spec.ts`)。
129
+ - **活动会话时切 mode。** 切 standing policy 会被**同步**拒绝,报 `cannot change sandbox mode … while persistent terminal sessions are open`:mode 事件未提交、生效 mode 不变、原会话仍可用;会话关闭后同一调用成功(`plugins/fish-shell/tests/terminal-session-real.spec.ts`)。
130
+ - **`read-only` 模式。** `read-only` 是文件效果模式,不是能力开关:六个工具仍然可见、PTY 仍能 spawn(motd `dsh>`),但解析后的 policy 完全没有 writable root,因此连 session workspace 内的写入也被拒绝——`read-only` 下 workspace 不是写边界(同一条 lane)。
131
+ - **跨 agent 的 owner 隔离。** 同 profile 的第二个 agent 看到空的 `terminal_list`,且读、写、关都不行:工具层报 `belongs to another agent`,背后的 registry code 是 `FOREIGN_SESSION`;owner 自己的会话毫发无损(同一条 lane)。
132
+ - **profile patch 直引 bundled 包名。** 该名字**只**从本 bundle 内部解析(`…/@banbolee/dsh-fish-shell/node_modules/@deepseek-ai/dsh-tool-terminal`),永远不来自 dsh 安装目录;解析不等于激活——因为 group 把 `terminals` isolate 了,该 row 停在 `pending (waiting for service: terminals)`,所以六个工具不会被注册第二次,且 boot 会把这行 pending 报到 stderr 而不是吞掉(`tests/composition/fish-pty-bundled-row.spec.ts`)。
133
+ - **未验证(不要当成已支持)**:
134
+ - `FishTerminalBackend` 的 submitted-setup 路径在 fish 3.7.1 上收敛:该库没有被 patch 挂载,因此不影响 L2 surface,老持久路径继续由它自己的 spec 覆盖;
135
+ - 完全无网的冷环境首装:打包 lane(`scripts/qa/fish-packed-offline.mjs`)确实用 `--offline` 安装,但它的 store 是先被仓库安装预热过的。
63
136
  - 仅支持 POSIX:`fish` 二进制与底层 process-group 语义在 Windows 上不可用(Windows 由 pwsh 系列覆盖)。
137
+
138
+ ## 排障
139
+
140
+ ### `@deepseek-ai/dsh-tool-terminal` 报 `ERR_MODULE_NOT_FOUND`
141
+
142
+ bundled 的工具包没有进安装产物,或它被当成 peer(profile 平面)依赖解析了。用 hoisted linker 构建:先 `pnpm install --prod --config.node-linker=hoisted`,再 `pnpm pack --config.node-linker=hoisted`。不带它 pnpm 会拒绝打包 bundled 依赖,并报 `ERR_PNPM_BUNDLED_DEPENDENCIES_WITHOUT_HOISTED`。检查产物:tarball 必须包含 `package/node_modules/@deepseek-ai/dsh-tool-terminal/package.json`。有一条上游原因不在我们能修的范围内:`@deepseek-ai/dsh-tool-terminal@0.2.0-rc.1` 声明的 `exports` 里有 `./src/*`,而实际发布的 tarball 并不包含它,因此任何按该子路径解析的代码都会 `ERR_MODULE_NOT_FOUND`。
143
+
144
+ ### `ERR_PNPM_NO_OFFLINE_TARBALL`
145
+
146
+ 离线安装打在了冷 pnpm store 上:`--offline` 下 pnpm 拒绝为它没有缓存的 tarball 走网络。先用一次联网安装把 store 预热(或首次安装时不带 `--offline` 跑同一条 `dsh plugin ... add`),然后再跑离线命令。插件自身的运行时依赖是随插件 tarball 一起走的,所以这是 store 状态问题,不是 bundled 文件缺失。
147
+
148
+ ### 交互式会话里嵌套的 `dsh`
149
+
150
+ 从本 bundle 起,会话会自动带上 harness home 契约:它的子进程环境里有 `DSH_HOME`(当 profile context 能提供时还有 `DSH_PROFILE` / `DSH_PROFILE_DIR`),取值来自与一次性 `fish` 工具同一个 `ctx.shellEnv` registry,因此两个 surface 不可能漂移——具体位置与理由见上方 **harness home 契约**。真实 lane 断言了这一结果:会话里 `echo $DSH_HOME` 打印的就是本 profile 的 home,`dsh --profile <profile> --dump-config` 组合出该 profile 的树,而同一个命令换到陌生 `DSH_HOME` 下会失败。因此在会话里嵌套启动的 `dsh` 会与打开它的会话跑在同一个 home、同一套插件上。
151
+
152
+ 老版本 bundle——以及任何 `terminal-fish-pty` row 仍指向官方 `@deepseek-ai/dsh-terminal-bash` 的部署——没有这份契约,而且对它们来说没有只改配置的解法:那个 row 没有 `env` 配置字段,写在它上面的 `env:` 会被 schema 收下、然后被静默忽略;后端的 `childEnvironment()` 也是 module-private 的。那里的嵌套 `dsh` 会把 home 解析成 `~/.dsh`,那里可能存着旧版本的 profile 与插件状态——残留的旧插件会让嵌套启动失败,或让它的 row 被禁用。正确做法是显式传入 home——把 `/path/to/dsh-home` 换成外层会话启动时使用的 Harness home(用一次 `fish` 调用即可打印:`echo $DSH_HOME`):
153
+
154
+ ```
155
+ env -u DSH_SESSION_ID -u DSH_PTY_SESSION_ID -u DSH_SHELL -u DSH_PERMISSION_MODE DSH_HOME=/path/to/dsh-home dsh --profile <profile>
156
+ ```
157
+
158
+ **维护注记。** 自己拥有这个 row(`terminal.js`)是我们能在不改官方包的前提下加上契约的唯一原因;一旦 `@deepseek-ai/dsh-terminal-bash` 支持了 `env` 配置字段(或 registry 自己转发 home 事实),row 就回到官方包、`terminal.js` 会被删除,本节只剩第一段——真实 lane 的断言在两种实现下都保留。那次退休同样要更新 `tests/docs-shape-fish-shell.spec.ts` 里硬编码的 `NESTED_DSH_ENTRY` 契约,因为它点名了这个模块。