@hyzyn/dsh-tty 0.18.3 → 0.19.1

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.
package/README.en.md CHANGED
@@ -87,9 +87,15 @@ Both were reproduced on **Windows 11 ARM (24H2) + Node 22 ARM64**. The fix:
87
87
  throughput / temperature show “无” (remote **Windows** hosts go through the PowerShell hop — see the
88
88
  status-bar section);
89
89
  - **How far this was verified**: on Windows 11 ARM a full install (`dsh plugin add @hyzyn/dsh-all`), all nine
90
- plugins mounting, `dsh web` serving, and the browser half being delivered (the same artifact macOS serves:
91
- 12,312,246 bytes, identical new-code markers), plus `[dsh-tty] mounted (shell=C:\WINDOWS\system32\cmd.exe)`.
90
+ plugins mounting, `dsh web` serving, and the browser half being delivered (the same artifact macOS serves;
91
+ size and new-code markers compared after a build rather than pinning a byte count, which changes on every
92
+ rebuild), plus `[dsh-tty] mounted (shell=C:\WINDOWS\system32\cmd.exe)`.
92
93
  x64 Windows is covered by the CI matrix (build / typecheck / test, see Development).
94
+ - **End-to-end coverage (0.19.0)**: `scripts/windows-smoke.mjs` drives a real `cmd.exe` through
95
+ spawn → input echo → kill → respawn on the CI windows-latest runner. It caught and now pins a
96
+ Windows-only failure class: force-killing a local PTY crashed the host — node-pty rejects signals
97
+ on Windows, and its `_deferNoArgs` re-throws that error from a socket callback, where the caller’s
98
+ try/catch cannot see it.
93
99
 
94
100
  ## Agent tools (P1)
95
101
 
@@ -98,16 +104,16 @@ The plugin injects thirteen tools into the agent (with the same power as the bas
98
104
  | Tool | Purpose |
99
105
  | --- | --- |
100
106
  | `tty_list` | List active terminal sessions (sid / kind (`local\|ssh`) / target / pid / **cwd tracked live as you `cd`** / activity time; tmux persistent sessions carry a `persist` marker) |
101
- | `tty_capture` | Read recent output (last N lines, ANSI stripped by default, `raw:true` for the raw stream); **`last:true` returns only the output + exit code of the previous completed command** (shell integration markers, see the next section) |
107
+ | `tty_capture` | Read recent output (last N lines, ANSI stripped by default, `raw:true` for the raw stream); **`last:true` returns only the output + exit code of the previous completed command** (shell integration markers, see the next section); when a command is **in flight** (just sent, completion marker not in yet) it returns `inProgress:true` without the stale result, so the previous command is never mistaken for this one (0.19.0) |
102
108
  | `tty_screen` | Read the **currently visible screen** as rendered (xterm-headless virtual screen, plain text) — it can genuinely read TUI interfaces such as vim / htop / menus |
103
- | `tty_expect` | Wait with a regex for a readiness signal in **subsequent output** (dev server URL, build finished, …); a timeout does not throw (`matched:false` + tail output), and a command that ends early also returns early with its exit code |
109
+ | `tty_expect` | Wait with a regex for a readiness signal in **subsequent output** (dev server URL, build finished, …); a timeout does not throw (`matched:false` + tail output), and a command that ends early also returns early with its exit code; at most 5 in-flight calls per session, and the accumulated window keeps only the last 64KB (0.19.0) |
104
110
  | `tty_send` | Send keys/text to a given session (such as `q` to a dev server, or a menu selection) |
105
- | `sftp_list` | List a remote SSH directory (name/type/size/mtime, directories first); `book` is the connection-book entry name and `path` defaults to the login home |
106
- | `sftp_read` | Read a remote **text** file (≤256KB by default, adjustable to 1MB, truncated beyond that; files with NUL bytes are rejected as binary) |
111
+ | `sftp_list` | List a remote SSH directory (name/type/size/mtime, directories first); `book` is the connection-book entry name and `path` defaults to the login home; at most 500 entries by default (`truncated:true` beyond that), and `isSymlink` distinguishes a symlink from a real directory (0.19.0) |
112
+ | `sftp_read` | Read a remote **text** file (≤256KB by default, adjustable to 1MB, truncated beyond that); `offset` pages from a given byte (handy for log tails), an invalid `maxBytes` errors out instead of silently falling back, and binary detection is a double test (NUL + illegal-UTF-8 ratio) (0.19.0) |
107
113
  | `sftp_write` | Write a remote text file (overwrite by default, `append:true` appends; ≤1MB per call) |
108
114
  | `sftp_mkdir` | Create a remote directory; `parents:true` fills in missing parents level by level (equivalent to `mkdir -p`, created bottom-up, existing directories skipped idempotently) |
109
115
  | `sftp_rename` | Rename/move a remote file or directory (a `to` in a different directory means a move; never overwrites an existing target) |
110
- | `sftp_remove` | Delete a remote file/directory; a directory uses rmdir by default (a non-empty one errors explicitly), and `recursive:true` deletes the whole tree (irrecoverable) |
116
+ | `sftp_remove` | Delete a remote file/directory; a directory uses rmdir by default (a non-empty one errors explicitly), and `recursive:true` deletes the whole tree (irrecoverable); paths pointing at `/`, `~`, or containing `.`/`..` segments are refused (guard for an irrecoverable operation, 0.19.0) |
111
117
  | `sftp_tree` | Recursively list a remote directory structure (depth-first, directories first; `maxDepth` 1~8 / `maxEntries` 1~2000 cap it, `truncated:true` when exceeded; symlinks are not followed, to avoid cycles) |
112
118
  | `tunnel_list` | List port-forwarding tunnels and their live state (active/connecting/error/stopped, rules, connection counts) |
113
119
 
@@ -252,7 +258,7 @@ slot (`ssh2`’s sftp subsystem, host half in `src/sftp.ts`):
252
258
  panel nor interrupts browsing); when another panel (such as the containers panel) already holds the slot on
253
259
  the same tab, or the panel is not open, it falls back to the original centered dialog, **without pushing
254
260
  anyone else’s panel out**. The title / collapse / ✕ come from tty’s mount slot;
255
- - **Follows the tab (0.18.4)**: the File Browser talks to the host of the tab that opened it, so it belongs to
261
+ - **Follows the tab (0.19.0)**: the File Browser talks to the host of the tab that opened it, so it belongs to
256
262
  that tab: switching away hides it (in-flight transfers keep running and the scene is restored when you come
257
263
  back), and closing the tab tears it down. **Every entry follows the same rule** — the connection bar’s
258
264
  “SFTP”, the 📂 on a connection-book entry and “File Browser” in the SSH dialog / settings card all become
@@ -395,7 +401,13 @@ and the agent tools all reuse the same scheduling.
395
401
  “Terminal N”); while connecting it first echoes a grey `Connecting user@host …`, and once ready the status
396
402
  bar shows `SSH user@host connected`; a failed connection (connection timeout / authentication rejected /
397
403
  host unreachable) comes back in an `error` frame with the reason, and since the tab spec was saved with the
398
- tab, clicking the terminal area reopens it from the original spec;
404
+ tab, clicking the terminal area reopens it from the original spec; the status bar describes the **currently
405
+ active tab** — switching or closing a tab immediately swaps in that tab’s own state (the failure reason and
406
+ the exit code are kept on the tab itself), so the red text no longer stays on screen after you close the tab
407
+ that could not connect (host-level messages such as “connection lost — reconnecting” belong to no tab and
408
+ are not wiped by a tab switch); conversely, **a background tab’s own failure is only recorded on that tab**
409
+ (its tab-bar status dot turns red and the terminal overlay carries the full text) and never takes over the
410
+ active tab’s status;
399
411
  - **Agent forwarding (0.4.0)**: with “agent forwarding” ticked in the SSH dialog, the remote side can use the
400
412
  local ssh-agent’s keys (such as `git clone` of a private repository remotely). It can be enabled with any
401
413
  authentication method (credentials still never touch disk); if no ssh-agent is running locally the
@@ -430,14 +442,17 @@ and the agent tools all reuse the same scheduling.
430
442
  box takes focus); the only difference is presentation — there it is an **inline** list rather than an overlay,
431
443
  because that card is a long scrollable form where an overlay would be clipped;
432
444
  - **Host-key TOFU pinning (0.3.0)**: after the first successful connection the host’s (host:port) sha256
433
- fingerprint is recorded in `hostKeys` (persisted with settings); every later connection is verified, a
434
- matching fingerprint is allowed, and **a changed fingerprint rejects the connection outright** (defense
435
- against impersonation), with a reset pointer in the error message. After a host reinstall or key change,
445
+ fingerprint is recorded in `hostKeys` (persisted with settings); every later connection is verified, any
446
+ fingerprint in the set matches, and **a changed fingerprint rejects the connection outright** (defense
447
+ against impersonation), with a reset pointer in the error message. A host’s multiple keys (0.19.0, e.g.
448
+ rsa + ed25519) are each recorded and merged into one record — algorithm negotiation changes no longer
449
+ cause false alarms. After a host reinstall or key change,
436
450
  delete the record under Settings → Plugins → Terminal Panel → “SSH host key
437
451
  records” and reconnect (the record list supports deletion). **“Import from known_hosts”
438
- (0.4.1)**: parses `~/.ssh/known_hosts` in one click to pre-fill existing host fingerprints in bulk (host
452
+ (0.4.1)**: parses `~/.ssh/known_hosts` in one click to pre-fill existing host fingerprints in bulk, keeping
453
+ all of a host’s rsa/ed25519 entries (no longer first-entry-only); host
439
454
  names from the connection book are also used to restore `|1|` hashed entries, and non-default ports are
440
- parsed as `[host]:port`);
455
+ parsed as `[host]:port`;
441
456
  - **Connection test (0.11.0)**: a “Test” button on each connection-book row of the settings card, plus a
442
457
  “Test connection” button in the SSH connection dialog — both perform **link diagnostics only** (no session,
443
458
  no `maxSessions` slot, no shell): first a TCP pre-check (DNS + connect, failures classified as
@@ -496,7 +511,7 @@ session belongs to (visually aligned with FinalShell’s session monitor bar):
496
511
  | `cwd` | host startup directory | Fallback working directory (the client’s current session cwd wins) |
497
512
  | `reconnectGraceSec` | 120 | Seconds a session is kept alive after an abnormal disconnect (0~3600): the session survives a page refresh/network blip waiting for a reconnect, and the reaper ends it on timeout; `0` = the old behavior, end immediately on disconnect |
498
513
  | `sshHosts` | `[]` | SSH connection book (selectable in the panel “+” menu): entries `{name, host, port=22, username, auth=agent\|key\|password, keyPath, passphrase, password, agentForward, persist=false}`; saved as a whole-set replacement, the same name overwrites; `password` / `passphrase` support `env:VAR` references so no plaintext is stored; with persistence on, clicking an entry opens a tmux persistent session by default, and `persist=false` is an **opt-out** |
499
- | `hostKeys` | `[]` | SSH host key records (TOFU, maintained automatically): entries `{host, port, fingerprint}`; unique by host:port, appended automatically on the first connection, and a changed fingerprint rejects the connection; the settings card can delete them to reset |
514
+ | `hostKeys` | `[]` | SSH host key records (TOFU, maintained automatically): entries `{host, port, fingerprints[]}` (the legacy single `fingerprint` field is migrated and merged on read); unique by host:port, one record holds all of a host’s keys, appended automatically on the first connection, any matching fingerprint is allowed, and a full mismatch rejects the connection; the settings card can delete them to reset |
500
515
  | `shellIntegration` | true | Injects the OSC 133/7 shell integration (command boundary markers + cwd reporting; `tty_capture{last}` depends on it); zsh/bash supported, other shells skipped automatically; can be turned off when compatibility problems appear |
501
516
  | `tunnels` | `[]` | Port-forwarding tunnels: entries `{name, bookName, direction=local\|remote, localPort?, remoteHost?, remotePort?, localTargetHost?, localTargetPort?, enabled}`; `bookName` references a connection-book entry for host and authentication; maintained graphically in the “Port forwarding” block of the card |
502
517
  | `sftpStyle` | `dialog` | SFTP File Browser UI style: `dialog` single pane (remote directory + upload/download/drag & drop) / `dual` two panes (local left / remote right, inline `⇨/⇦` server-side direct transfer); reopen SFTP for it to take effect |
@@ -610,7 +625,7 @@ which stays visible, clickable and typable instead of being covered by a full-sc
610
625
  the pain before 0.15).
611
626
 
612
627
  A mount slot is **connection-scoped**: its credentials / target come from the terminal tab that opened it.
613
- So since 0.18.4 every pane records its **owner tab** (`options.ownerSid`, defaulting to the active tab at
628
+ So since 0.19.0 every pane records its **owner tab** (`options.ownerSid`, defaulting to the active tab at
614
629
  mount time): switching to another tab **hides** the pane (`data-dock-hidden`; its DOM and your rendered tree
615
630
  survive, in-flight transfers keep running) and switching back restores the scene; closing the owner tab tears
616
631
  the pane down. Without this, the pane stayed put across a tab switch — its title read `SFTP · cdc-test-161`
@@ -663,10 +678,11 @@ ctx.inject(['ttyPanel'], (c) => {
663
678
  - The title bar (title / collapse / ✕) is provided by tty, and consumers only own their own body; a throwing
664
679
  `onClose` is only logged with `console.warn`, without affecting closing the panel.
665
680
 
666
- > Contract versions: `ttyConnbar.version === 1`, `ttyTerminal.version === 2` (1 = `open` only,
667
- > 2 = adds `mount`), `ttyPanel.version === 1`. Consumers **decide capabilities by version number**; do not
668
- > rely on assumptions beyond `typeof fn === 'function'`; on an older tty the `inject` still fires, but the
669
- > corresponding fields are absent.
681
+ > Contract versions: `ttyConnbar.version === 1`, `ttyTerminal.version === 3` (1 = `open` only,
682
+ > 2 = adds `mount`, 3 = `open` reuses an existing live tab for the same connection + command by default),
683
+ > `ttyPanel.version === 2` (1 = `mountPane` + `isOpen`, 2 = adds `minimize`). Consumers **decide capabilities
684
+ > by version number**; do not rely on assumptions beyond `typeof fn === 'function'`; on an older tty the
685
+ > `inject` still fires, but the corresponding fields are absent.
670
686
 
671
687
  ## Frame protocol (/api/dsh-tty/ws, JSON text frames; v3 = one connection with many sessions + reconnect)
672
688
 
@@ -705,9 +721,17 @@ pnpm --filter @hyzyn/dsh-tty build # tsc host + esbuild browser half (cli
705
721
  pnpm --filter @hyzyn/dsh-tty typecheck
706
722
  pnpm --filter @hyzyn/dsh-tty probe # M0 probe: PTY primitive verification (needs a real PTY)
707
723
  pnpm --filter @hyzyn/dsh-tty integration # integration tests: real plugin × real DSH service composition
708
- pnpm --filter @hyzyn/dsh-tty live # liveness smoke against a running dsh web
709
- pnpm --filter @hyzyn/dsh-tty tui # TUI smoke: vim/nano full-screen rendering
724
+ pnpm --filter @hyzyn/dsh-tty live # liveness smoke: start dsh web first (default ws://127.0.0.1:3080; DSH_TTY_WS_URL overrides)
725
+ pnpm --filter @hyzyn/dsh-tty tui # TUI smoke: vim/htop full-screen rendering (start dsh web first, default :3090; DSH_TTY_WS_URL overrides)
710
726
  pnpm --filter @hyzyn/dsh-tty ssh-smoke # SSH smoke: in-memory SSH server (ssh2.Server) × real spawnSsh end to end (build first)
727
+ pnpm --filter @hyzyn/dsh-tty probe-smoke # probe classification & TOFU (7 cases, self-contained)
728
+ pnpm --filter @hyzyn/dsh-tty probe-route-smoke # probe HTTP route + connection book (9 cases)
729
+ pnpm --filter @hyzyn/dsh-tty sftplimits-smoke # sftpLimits normalisation (6 cases)
730
+ pnpm --filter @hyzyn/dsh-tty windows-smoke # Windows end to end (only meaningful on Windows; skips elsewhere)
731
+
732
+ CI (`.github/workflows/ci.yml`) runs `integration` + `ssh-smoke` + the three smokes on ubuntu and
733
+ `windows-smoke` on windows-latest, plus a “build artifacts match sources” gate (`client.js` + `lib/`).
734
+ The 48 fixes shipped in 0.19.0 and their audit index live in [`DEFECTS.md`](./DEFECTS.md).
711
735
  pnpm --filter @hyzyn/dsh-tty preview # visual preview: headless Chrome screenshots per scene (see below)
712
736
  ```
713
737
 
@@ -732,14 +756,14 @@ node scripts/preview.mjs --theme=light # light theme
732
756
 
733
757
  Coverage: local terminal / multi-tab + SSH connection bar / the “+” menu / SSH dialog (new, edit, probe)/
734
758
  settings card (also side by side with docker)/ SFTP (single pane, dual pane, placement fallback)/
735
- **mount slot follows the tab** (`dock-pane-tab`, the 0.18.4 regression)/ minimized badge / exit and error
759
+ **mount slot follows the tab** (`dock-pane-tab`, the 0.19.0 regression)/ minimized badge / exit and error
736
760
  overlays / tunnel popover / search box / toast / embedded terminals (alone and alongside the panel)/
737
761
  docker panel and “containers → terminal drawer”.
738
762
 
739
763
  A scene may attach a **function-shaped** assertion to `window.__previewAssert` (returning `null` means pass,
740
764
  a string / array means fail); the script runs it and folds the result into `✓/✗`. An assertion that only
741
765
  lives in the fixture, seen by nobody unless someone pulls `diag` by hand, is a regression that is not really
742
- pinned — which is exactly what bit the 0.18.4 “panel does not follow the tab” fix: the assertion was written
766
+ pinned — which is exactly what bit the 0.19.0 “panel does not follow the tab” fix: the assertion was written
743
767
  already, but because it was mixed into a `diag` object containing a function, the whole evaluation failed
744
768
  silently and everything reported ✓.
745
769
 
@@ -818,15 +842,22 @@ verify things like “is there still a white panel after switching light/dark th
818
842
  sandbox/chroot); downloads go through browser memory (for very large files prefer `scp`/`rsync` in the
819
843
  terminal); the agent tool `sftp_read` is ≤1MB and rejects binaries, and `sftp_write` is ≤1MB per call (use
820
844
  panel upload or the terminal for larger content); the `sftp_*` tools accept only connection-book entry
821
- names, and inline credentials are for the panel dialog only.
845
+ names, and inline credentials are for the panel dialog only; overwrite writes go through a same-directory
846
+ temp part `.dsh-part-<uuid>` + rename (atomic) — if the host process crashes / loses power, a part orphan
847
+ may remain, and the next overwrite upload to the same directory automatically cleans parts older than 24h.
822
848
  - **SSH host keys are TOFU-pinned**: the first connection records the sha256 fingerprint automatically
823
- (trust on first use), after which a matching fingerprint is allowed and a change is rejected — no longer an
849
+ (trust on first use), after which any recorded fingerprint matching is allowed and a full mismatch is
850
+ rejected — no longer an
824
851
  unconditional accept-and-log. Note TOFU’s inherent boundary: if the first connection already met a MITM,
825
852
  what was recorded is a fake fingerprint; `hostKeys` is persisted with settings, and a fingerprint change
826
- requires a human to confirm in the settings card and delete the record; `hostKeys` stores only **one**
827
- fingerprint per host:port — when a host offers several key types (rsa/ed25519/ecdsa) and algorithm
828
- negotiation changes, it may report a false change, and deleting the record and reconnecting recalibrates
829
- it; known_hosts import likewise takes the first entry per host.
853
+ requires a human to confirm in the settings card and delete the record; a host’s multiple key types
854
+ (rsa/ed25519/ecdsa) are merged into one record’s fingerprint set (0.19.0), so algorithm negotiation
855
+ changes no longer report a false “fingerprint changed”; known_hosts import likewise keeps every
856
+ fingerprint of a host.
857
+ - **Browser tab persistence contains no plaintext credentials** (0.19.0): the spec copies that SSH tabs
858
+ write into sessionStorage / localStorage have plaintext `password` / `passphrase` stripped (`env:`
859
+ references are kept) and are flagged with `credsStripped` — on restore/respawn the terminal asks for
860
+ re-entry. The in-memory spec of the live session is unaffected.
830
861
  - **SSH passwords / passphrases should use `env:VAR` references**: the connection book is persisted in the
831
862
  settings file, so plaintext `password` / `passphrase` is an exposure surface; prefer `env:VAR` +
832
863
  dsh-env-manager, or `agent` authentication outright (credentials never touch disk).
package/README.md CHANGED
@@ -83,9 +83,13 @@ dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
83
83
  - **服务器状态条**:本地只拿得到 CPU / 内存 / 在线时长(`node:os`),磁盘 / TCP / 网速 / 温度
84
84
  显示「无」(**远端** Windows 主机走 PowerShell 那一跳,见「服务器状态条」一节);
85
85
  - **验证到什么程度**:Windows 11 ARM 上跑过完整安装(`dsh plugin add @hyzyn/dsh-all`)、九个插件
86
- 装载、`dsh web` 起服务与浏览器半体交付(与 macOS 上服务的是同一份产物:12 312 246 字节、
87
- 新代码标记一致)、`[dsh-tty] mounted (shell=C:\WINDOWS\system32\cmd.exe)`;x64 Windows 由 CI
86
+ 装载、`dsh web` 起服务与浏览器半体交付(与 macOS 上服务的是同一份产物,构建后比对大小与
87
+ 内容标记一致;不写死字节数——每次重建都会变)、`[dsh-tty] mounted (shell=C:\WINDOWS\system32\cmd.exe)`;x64 Windows 由 CI
88
88
  的三平台矩阵覆盖(build / typecheck / test,见「开发」)。
89
+ - **端到端覆盖(0.19.0)**:`scripts/windows-smoke.mjs` 在 CI 的 windows-latest 上跑真实 `cmd.exe`
90
+ 的 spawn → 输入回显 → kill → 重开。它抓出并钉住了「强杀本地 PTY 会崩宿主」这类只在 Windows
91
+ 出现的问题——node-pty 在 Windows 上不接受 signal,而它的 `_deferNoArgs` 会把这个异常推迟到
92
+ socket 回调里抛出,调用方的 try/catch 拦不住。
89
93
 
90
94
  ## agent 工具(P1)
91
95
 
@@ -94,16 +98,16 @@ dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
94
98
  | 工具 | 作用 |
95
99
  | --- | --- |
96
100
  | `tty_list` | 列出活跃终端会话(sid / kind(local\|ssh)/ target / pid / **cwd 实时跟随 cd** / 活动时间;tmux 持久会话带 `persist` 标记) |
97
- | `tty_capture` | 读取近期输出(尾部 N 行,默认清洗 ANSI,`raw:true` 取原始流);**`last:true` 只返回上一条已完成命令的输出 + 退出码**(shell 集成标记,见下节) |
101
+ | `tty_capture` | 读取近期输出(尾部 N 行,默认清洗 ANSI,`raw:true` 取原始流);**`last:true` 只返回上一条已完成命令的输出 + 退出码**(shell 集成标记,见下节);命令**在途**时(刚发送、完成标记未到)返回 `inProgress:true` 且不带旧结果——避免把上一条的输出当成这一条(0.19.0) |
98
102
  | `tty_screen` | 读取**当前可见屏幕**的渲染结果(xterm-headless 虚拟屏,纯文本)——能真正读懂 vim / htop / 菜单等 TUI 界面 |
99
- | `tty_expect` | 用正则**等待后续输出**中的就绪信号(dev server URL、构建完成等);超时不抛错(`matched:false` + 尾部输出),命令提前结束也会带退出码早停 |
103
+ | `tty_expect` | 用正则**等待后续输出**中的就绪信号(dev server URL、构建完成等);超时不抛错(`matched:false` + 尾部输出),命令提前结束也会带退出码早停;同一会话在途调用最多 5 个,累积窗口只保留尾部 64KB(0.19.0) |
100
104
  | `tty_send` | 向指定会话发送按键/文本(如 dev server 的 q 键、菜单选择) |
101
- | `sftp_list` | 列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前);`book` 为连接簿条目名,`path` 缺省为登录 home |
102
- | `sftp_read` | 读取远程**文本**文件(默认 ≤256KB 可调至 1MB,超出截断;含 NUL 字节按二进制拒绝) |
105
+ | `sftp_list` | 列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前);`book` 为连接簿条目名,`path` 缺省为登录 home;默认最多 500 项(超限 `truncated:true`),`isSymlink` 区分软链与真目录(0.19.0) |
106
+ | `sftp_read` | 读取远程**文本**文件(默认 ≤256KB 可调至 1MB,超出截断);`offset` 可从指定字节分页(适合读日志尾部),非法 `maxBytes` 直接报错,二进制判定 = NUL + 非法 UTF-8 占比双判据(0.19.0) |
103
107
  | `sftp_write` | 写远程文本文件(默认覆盖,`append:true` 追加;单次 ≤1MB) |
104
108
  | `sftp_mkdir` | 创建远程目录;`parents:true` 逐级补齐缺失父目录(等效 `mkdir -p`,自底向上创建,已存在目录幂等跳过) |
105
109
  | `sftp_rename` | 重命名/移动远程文件或目录(`to` 与 `from` 不同目录即移动;不覆盖已存在的目标) |
106
- | `sftp_remove` | 删除远程文件/目录;目录默认 rmdir(非空明确报错),`recursive:true` 整树删除(不可恢复) |
110
+ | `sftp_remove` | 删除远程文件/目录;目录默认 rmdir(非空明确报错),`recursive:true` 整树删除(不可恢复);会拒绝 `/`、`~`、含 `.`/`..` 段的路径(不可恢复操作的前置护栏,0.19.0) |
107
111
  | `sftp_tree` | 递归列举远程目录结构(深度优先、目录优先;`maxDepth` 1~8 / `maxEntries` 1~2000 限流,超限 `truncated:true`;symlink 不跟随防环) |
108
112
  | `tunnel_list` | 列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、连接数) |
109
113
 
@@ -228,7 +232,7 @@ subsystem,宿主半体 `src/sftp.ts`):
228
232
  一条标题栏(折叠不关面板、不中断浏览);同一标签的挂载位已被别的面板(如容器面板)
229
233
  占用、或面板没开时,退回原来的居中对话框,**不会把别人的面板挤掉**。标题 / 折叠 / ✕ 由
230
234
  tty 的挂载位提供;
231
- - **跟着标签切(0.18.4)**:文件浏览连的是**打开它的那个标签**那台主机,所以它归属该标签:
235
+ - **跟着标签切(0.19.0)**:文件浏览连的是**打开它的那个标签**那台主机,所以它归属该标签:
232
236
  切到别的标签时整块收起(在途传输照跑,切回来还在原地),标签关掉时一并收掉。**所有入口
233
237
  用同一条规则**——连接栏「SFTP」、连接簿条目的 📂、SSH 对话框 / 设置卡片的「文件浏览」都
234
238
  归属打开那一刻的活动标签;只有「面板开着但一个标签都没有」才算不隶属任何标签(永远可见)。
@@ -354,7 +358,11 @@ tmux server(专用 socket `dsh-tty`,与用户自己的 tmux 完全隔离)
354
358
  「终端 N」);连接中先回显灰字 `Connecting user@host …`,就绪后状态栏
355
359
  显示 `SSH user@host 已连接`;连接失败(连接超时 / 认证被拒 / 主机
356
360
  不可达)以 `error` 帧带回原因,标签规格已随标签保存,点终端区域可按
357
- 原规格重开;
361
+ 原规格重开;状态栏描述的是**当前活动标签**——切标签 / 关标签立刻换成
362
+ 该标签自己的状态(失败原因、退出码都记在标签身上),所以关掉那个连不上
363
+ 的标签后,红字不会继续挂在头上(宿主级消息如「连接断开 — 自动重连中」
364
+ 不属于任何标签,不会被切标签抹掉);反过来说,**后台标签自己的失败只记在
365
+ 它身上**(标签栏状态点转红、终端区浮层带原文),不会顶掉活动标签的显示;
358
366
  - **agent forwarding(0.4.0)**:SSH 对话框勾选「agent forwarding」后远程
359
367
  可用本地 ssh-agent 的钥匙(远程 `git clone` 私有仓库等)。任意认证方式下
360
368
  都可开(凭证仍不落盘);本机未运行 ssh-agent 时连接会明确报错而非静默
@@ -384,12 +392,14 @@ tmux server(专用 socket `dsh-tty`,与用户自己的 tmux 完全隔离)
384
392
  可滚动的长表单,浮层在那边会被裁掉;
385
393
  - **主机指纹 TOFU 钉扎(0.3.0)**:首次连接成功后把该主机(host:port)的
386
394
  sha256 指纹记录进 `hostKeys`(随 settings 持久化);之后每次连接校验,
387
- 指纹一致放行,**指纹变更直接拒绝连接**(防中间人冒充),错误信息带重置
388
- 指引。主机重装/换钥匙后,到 设置 → 插件 → 终端面板 → 「SSH 主机密钥
389
- 记录」删除对应记录再重连即可(记录列表支持删除)。**「从 known_hosts
390
- 导入」(0.4.1)**:一键解析 `~/.ssh/known_hosts` 把已有主机指纹批量
391
- 预填充(连接簿里的主机名还会用于还原 `|1|` hashed 条目,非默认端口按
392
- `[host]:port` 解析);
395
+ 集合内任一指纹匹配即放行,**指纹变更直接拒绝连接**(防中间人冒充),错误
396
+ 信息带重置指引。同一主机的多把钥匙(0.19.0,如 rsa + ed25519)各记一条
397
+ 指纹、合并存于同一记录——算法协商变化不再误报变更。主机重装/换钥匙后,
398
+ 到 设置 → 插件 → 终端面板 → 「SSH 主机密钥记录」删除对应记录再重连即可
399
+ (记录列表支持删除)。**「从 known_hosts 导入」(0.4.1)**:一键解析
400
+ `~/.ssh/known_hosts` 把已有主机指纹批量预填充,同主机的 rsa/ed25519 等多条
401
+ 记录全部保留(不再只留首条);连接簿里的主机名还会用于还原 `|1|` hashed
402
+ 条目,非默认端口按 `[host]:port` 解析;
393
403
  - **连接测试(0.11.0)**:设置卡片连接簿条目行内「测试」按钮,SSH 连接
394
404
  对话框另有「试连」按钮——两者都只做**链路诊断**(不建会话、不占
395
405
  `maxSessions` 名额、不开 shell):先 TCP 预检(DNS + 建连,失败分类为
@@ -444,7 +454,7 @@ tmux server(专用 socket `dsh-tty`,与用户自己的 tmux 完全隔离)
444
454
  | `cwd` | 宿主启动目录 | 兜底工作目录(客户端当前会话 cwd 优先) |
445
455
  | `reconnectGraceSec` | 120 | 异常断开后会话保活秒数(0~3600):刷新页面/网络抖动后会话存活等待重连,超时由回收器结束;`0` = 旧行为,断开立即结束 |
446
456
  | `sshHosts` | `[]` | SSH 连接簿(面板「+」菜单可选):条目 `{name, host, port=22, username, auth=agent\|key\|password, keyPath, passphrase, password, agentForward, persist=false}`;保存时整体替换、同名覆盖;`password` / `passphrase` 支持 `env:VAR` 引用,避免明文入库;持久化开启时条目点击默认以 tmux 持久会话打开,`persist=false` 是**取消项** |
447
- | `hostKeys` | `[]` | SSH 主机指纹记录(TOFU,自动维护):条目 `{host, port, fingerprint}`;按 host:port 唯一,首次连接自动追加,指纹变更拒绝连接;设置卡片可删除重置 |
457
+ | `hostKeys` | `[]` | SSH 主机指纹记录(TOFU,自动维护):条目 `{host, port, fingerprints[]}`(旧版单 `fingerprint` 字段读取时自动迁移合并);按 host:port 唯一,一机多把钥匙共用一条记录,首次连接自动追加、任一指纹匹配放行、全部不匹配拒绝连接;设置卡片可删除重置 |
448
458
  | `shellIntegration` | true | 注入 OSC 133/7 shell 集成(命令边界标记 + cwd 上报;`tty_capture{last}` 依赖它);zsh/bash 支持,其他 shell 自动跳过;出兼容问题时可关闭 |
449
459
  | `tunnels` | `[]` | 端口转发隧道:条目 `{name, bookName, direction=local\|remote, localPort?, remoteHost?, remotePort?, localTargetHost?, localTargetPort?, enabled}`;`bookName` 引用连接簿条目提供主机与认证;卡片「端口转发」区块可视化维护 |
450
460
  | `sftpStyle` | `dialog` | SFTP 文件浏览界面风格:`dialog` 单窗体(远程目录 + 上传/下载/拖拽)/ `dual` 双栏(左本机 / 右远程,行内 `⇨/⇦` 宿主服务端直传);重新打开 SFTP 生效 |
@@ -564,7 +574,7 @@ ctx.inject(['ttyTerminal'], (c) => {
564
574
  连接栏点「容器」,容器面板挂在终端**右侧**,终端继续可见、可点、可输入,而不是被整屏
565
575
  弹窗盖住(0.15 之前那正是用户的痛点)。
566
576
 
567
- 挂载位是**连接级**的:它的凭证 / 目标来自**打开它的那个终端标签**。所以 0.18.4 起每块
577
+ 挂载位是**连接级**的:它的凭证 / 目标来自**打开它的那个终端标签**。所以 0.19.0 起每块
568
578
  pane 记一个「归属标签」(`options.ownerSid`,默认 = 挂载那一刻的活动标签):切到别的
569
579
  标签时整块**收起**(`data-dock-hidden`,DOM 与你 render 的树都保活,在途传输继续跑),
570
580
  切回来恢复现场;归属标签被关掉时 pane 一并收掉。不这么做的话,切完标签面板还停在原处
@@ -627,7 +637,7 @@ ctx.inject(['ttyPanel'], (c) => {
627
637
  })
628
638
  ```
629
639
 
630
- > 契约版本:`ttyConnbar.version === 1`、`ttyTerminal.version === 2`(1 = 只有 `open`,
640
+ > 契约版本:`ttyConnbar.version === 1`、`ttyTerminal.version === 3`(1 = 只有 `open`,
631
641
  > 2 = 增加 `mount`,3 = `open` 默认复用同「连接 + 命令」的活标签)、`ttyPanel.version === 2`(1 = `mountPane` + `isOpen`,2 = 增加
632
642
  > `minimize`)。消费方**按版本号判断能力**,不要用
633
643
  > `typeof fn === 'function'` 之外的假设;老版本 tty 上 `inject` 依然会触发,但没有
@@ -669,9 +679,17 @@ pnpm --filter @hyzyn/dsh-tty build # tsc 宿主 + esbuild 浏览器半体
669
679
  pnpm --filter @hyzyn/dsh-tty typecheck
670
680
  pnpm --filter @hyzyn/dsh-tty probe # M0 探针:PTY 原语验证(需真实 PTY)
671
681
  pnpm --filter @hyzyn/dsh-tty integration # 集成测试:真实插件 × 真实 DSH 服务组合
672
- pnpm --filter @hyzyn/dsh-tty live # 对运行中的 dsh web 做存活冒烟
673
- pnpm --filter @hyzyn/dsh-tty tui # TUI 冒烟:vim/nano 全屏渲染
682
+ pnpm --filter @hyzyn/dsh-tty live # 存活冒烟:需先起 dsh web(默认连 ws://127.0.0.1:3080,DSH_TTY_WS_URL 可覆盖)
683
+ pnpm --filter @hyzyn/dsh-tty tui # TUI 冒烟:vim/htop 全屏渲染(需先起 dsh web,默认连 :3090,DSH_TTY_WS_URL 可覆盖)
674
684
  pnpm --filter @hyzyn/dsh-tty ssh-smoke # SSH 冒烟:内存 SSH server(ssh2.Server)× 真实 spawnSsh 端到端(需先 build)
685
+ pnpm --filter @hyzyn/dsh-tty probe-smoke # 试连分类与 TOFU(7 例,自包含)
686
+ pnpm --filter @hyzyn/dsh-tty probe-route-smoke # 试连 HTTP 路由 + 连接簿(9 例)
687
+ pnpm --filter @hyzyn/dsh-tty sftplimits-smoke # sftpLimits 配置归一化(6 例)
688
+ pnpm --filter @hyzyn/dsh-tty windows-smoke # Windows 端到端(只在 Windows 上有意义,其他平台打印原因后跳过)
689
+
690
+ CI(`.github/workflows/ci.yml`)在 ubuntu 跑 `integration` + `ssh-smoke` + 三个 smoke,在
691
+ windows-latest 跑 `windows-smoke`,另有一道「构建产物与源码一致」闸门(`client.js` + `lib/`)。
692
+ 0.19.0 的 48 项修复与审计索引见 [`DEFECTS.md`](./DEFECTS.md)。
675
693
  pnpm --filter @hyzyn/dsh-tty preview # 视觉预览:headless Chrome 逐场景截图(见下)
676
694
  ```
677
695
 
@@ -695,12 +713,12 @@ node scripts/preview.mjs --theme=light # 浅色主题
695
713
 
696
714
  覆盖:本地终端 / 多标签 + SSH 连接栏 / 「+」菜单 / SSH 对话框(新建、编辑、试连)/
697
715
  设置卡片(含与 docker 并排对照)/ SFTP(单窗体、双栏、落点回退)/
698
- **挂载位跟着标签切**(`dock-pane-tab`,0.18.4 的回归)/ 最小化徽标 / 退出与错误遮罩 /
716
+ **挂载位跟着标签切**(`dock-pane-tab`,0.19.0 的回归)/ 最小化徽标 / 退出与错误遮罩 /
699
717
  隧道弹层 / 搜索框 / toast / 嵌入式终端(单独与面板共存)/ docker 面板与「容器 → 终端抽屉」。
700
718
 
701
719
  场景可以把**函数形态**的断言挂到 `window.__previewAssert`(返回 `null` = 通过,返回
702
720
  字符串 / 数组 = 失败),脚本会跑掉它并把结果计入 `✓/✗`。断言光挂在夹具里、只有手工
703
- 取 `diag` 时才有人看,回归等于没钉——0.18.4 修「面板不跟标签切」时就吃到这个亏:断言
721
+ 取 `diag` 时才有人看,回归等于没钉——0.19.0 修「面板不跟标签切」时就吃到这个亏:断言
704
722
  早写好了,但因为混在带函数的 `diag` 对象里,整条求值静默失败,一路都是 ✓。
705
723
 
706
724
  夹具还会把 `--dsw-*` 皮肤变量与真实界面一并渲染,因此能验
@@ -748,14 +766,18 @@ node scripts/preview.mjs --theme=light # 浅色主题
748
766
  反馈补丁兼容。
749
767
  - **端口转发边界**:本地监听固定 127.0.0.1(不暴露局域网);remote 方向服务端监听还受服务端 sshd `GatewayPorts` 限制;隧道的 SSH 连接与终端会话独立,均走 TOFU 钉扎与连接簿认证;隧道规格变更(端口/目标/启停)经「保存」热生效,热改连接簿凭证则在下次重连时生效。
750
768
  - **会话持久化(tmux)边界**:持久标签由 tmux server(专用 socket `dsh-tty`)托管——宿主被硬杀 / 保活回收 / 浏览器丢失标签规格时,tmux 会话会**留存**(这正是恢复能力的前提),直到机器重启或手动 `tmux -L dsh-tty kill-server`;agent 命令粒度工具(capture{last}/expect)依赖 tmux ≥3.3 的 DCS `allow-passthrough`,更低版本持久化可用但该能力降级(SSH 远程会话不注入 shell 集成钩子,capture{last} 本就不可用,与持久化无关);恢复接回重画的是当前可见屏,断线前的滚动历史在 tmux 自己的 history buffer(copy-mode)里,不在外层 xterm 滚动区;持久标签的 `exit` 帧退出码是 tmux 客户端的(0),shell 退出码经 OSC 133;D 标记照常可用;`tmux.conf` 只在 tmux server 首启时读取(改配置后需 `tmux -L dsh-tty kill-server` 让下次 spawn 重建 server);`grace=0` 的「断开立即结束」对持久标签同样会 kill-session(tmux 会话不存活);持久 SSH 会话要求远程装有 tmux(未装自动降级普通会话,连接栏常驻「未持久化」标记),且远程 `~/.tmux.conf` 不影响专用 socket 的独立 conf(`-f /dev/null`);SSH 持久会话名随 settings 留存;**两个窗口同时接回同一持久会话**时共享同一个宿主 PTY(0.10.1,单客户端扇出,名额不翻倍),两侧行数以最近调整尺寸的窗口为准(尺寸不一致时较大一侧由 onResize 自愈重画)。
751
- - **SFTP 边界**:文件权限 = 对应 SSH 账号的终端权限(无额外沙箱/chroot);下载经浏览器内存(超大文件建议终端 `scp`/`rsync`);agent 工具 `sftp_read` ≤1MB 且拒绝二进制、`sftp_write` 单次 ≤1MB(大内容走面板上传或终端);`sftp_*` 工具只收连接簿条目名,内联凭证仅供面板对话框使用。
769
+ - **SFTP 边界**:文件权限 = 对应 SSH 账号的终端权限(无额外沙箱/chroot);下载经浏览器内存(超大文件建议终端 `scp`/`rsync`);agent 工具 `sftp_read` ≤1MB 且拒绝二进制、`sftp_write` 单次 ≤1MB(大内容走面板上传或终端);`sftp_*` 工具只收连接簿条目名,内联凭证仅供面板对话框使用;覆盖写走同目录临时分片 `.dsh-part-<uuid>` + rename 原子落盘——宿主进程崩溃 / 断电时可能留下分片孤儿(下次向同目录覆盖上传时会自动清理超过 24h 的残留)。
752
770
  - **SSH host key 为 TOFU 钉扎**:首次连接自动记录 sha256 指纹(trust on
753
- first use),之后指纹一致放行、变更拒绝——不再是无条件放行的
771
+ first use),之后任一记录指纹匹配放行、全部不匹配拒绝——不再是无条件放行的
754
772
  accept-and-log。注意 TOFU 的固有边界:首次连接若已遭遇 MITM 则记录的
755
773
  就是伪指纹;`hostKeys` 随 settings 落盘,指纹变更需人工在设置卡片确认
756
- 并删除记录;`hostKeys` 按 host:port 只存**一条**指纹——同一主机提供多种
757
- 密钥类型(rsa/ed25519/ecdsa)且算法协商变化时可能误报变更,删除记录
758
- 重连即可重新校准;known_hosts 导入同为每主机首条优先。
774
+ 并删除记录;同一主机的多把钥匙(rsa/ed25519/ecdsa)合并为一条记录的多
775
+ 指纹集合(0.19.0),算法协商变化不再误报「指纹变更」;known_hosts 导入
776
+ 同样保留同主机的全部指纹。
777
+ - **浏览器标签持久化不含明文凭证**(0.19.0):SSH 标签写入
778
+ sessionStorage / localStorage 的规格副本会剥掉明文 `password` / `passphrase`
779
+ (`env:` 引用保留),并打 `credsStripped` 标记——恢复/重开时终端里提示
780
+ 重新输入。本会话内存里的规格不受影响。
759
781
  - **SSH 密码 / 口令建议 `env:VAR` 引用**:连接簿随 settings 文件落盘,
760
782
  `password` / `passphrase` 明文入库有泄露面;建议 `env:VAR` +
761
783
  dsh-env-manager 托管,或直接用 `agent` 认证(凭证不落盘)。