@hyzyn/dsh-docker 0.9.6 → 0.11.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.
package/README.en.md CHANGED
@@ -201,13 +201,23 @@ Inside the panel:
201
201
  may have fallen behind while the stream was down), including the case where the daemon never came up and the
202
202
  stream never opened at all — otherwise the strip turns green while the list still shows its error state, looking
203
203
  fixed when it is not.
204
+ **A refused reconnect must say why** (`D164`): when the server actively refuses this stream it sends an
205
+ `event: error` frame carrying a `message` (typically the long-stream quota being full — see "Known limitations";
206
+ that sentence says what to do about it). The client now **shows it on the Activity strip** ("Reconnect refused: …")
207
+ instead of discarding it. It used to just set `closed`, which made "refused → back to where it
208
+ was" indistinguishable from "the click did nothing", leaving the user to click over and over. The reason is
209
+ cleared on a successful reconnect (or when the stream is rebuilt).
210
+ The hint on the right of that header row is **small text inside a single line**, so it is deliberately short
211
+ (2026-10-08 user feedback: "verbose and stiff"): the state label on the left already says the stream is closed,
212
+ so this only says "will not recover on its own — click Reconnect". The cause, the cap and the `MaxSessions`
213
+ details live in this page, not in the UI.
204
214
  - **Select for merging (temporary multi-select merged logs)**: the toolbar's `Select for merging` enters selection
205
215
  mode — a checkbox appears on the left of every card, clicking a card body becomes **select / deselect** (it no
206
216
  longer opens details; the action bar collapses temporarily so that multi-selecting does not mis-click
207
217
  start / stop / remove), and an action bar appears between the toolbar and the list: "N containers selected" +
208
- `merged logs` + `Cancel`. `merged logs` needs at least 2 containers; at 7–8 selected it gives a soft hint
209
- (browsers limit same-origin concurrent long connections), and above **8** the button is greyed out with a hint
210
- about the cap. Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
218
+ `merged logs` + `Cancel`. `merged logs` needs at least 2 containers; selecting the 3rd gives a soft hint, and above
219
+ **3** the button is greyed out with a hint about the cap (**where that cap comes from is in "Known limitations" —
220
+ the browser's per-origin connection budget**; the selection bar states it too). Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
211
221
  project view (one `/logs/stream` per container, mixed by the `[service]` / container-name prefix, with filtering
212
222
  and auto-scroll), and going back exits selection mode and clears it. The merged view's **content controls are
213
223
  fully aligned with the single-container log view** (text filter + level threshold + `⬇ .log` / `⬇ .md` exporting
@@ -219,7 +229,7 @@ Inside the panel:
219
229
  refresh are pruned by id.
220
230
  - **One-click selection by condition** (in selection mode): the action bar's second row offers a row of condition
221
231
  chips (`all visible / unhealthy / needs attention / stopped`, plus `same image / same project` once something is
222
- selected), with counts **truncated to the remaining slots** — a chip reading 8 really does select 8; anything over
232
+ selected), with counts **truncated to the remaining slots** — a chip reading 3 really does select 3; anything over
223
233
  the cap is stated honestly in the title and the result hint. Conditions only apply within the
224
234
  **current filtered result** (search / filter the state first, then select in one click).
225
235
  - **Container cards**: "label + value" rows matching the reference layout (image / ID / ports / created /
@@ -228,7 +238,7 @@ Inside the panel:
228
238
 
229
239
  | Group | Buttons | Description |
230
240
  | --- | --- | --- |
231
- | View / enter | Terminal, logs, resource usage | Does not change container state; always available even in read-only mode |
241
+ | View / enter | Terminal, logs, resource usage | Does not change container state; always available even in read-only mode. **The Terminal button runs an interactive `docker exec -it` shell and is not gated by `allowExec`** (that switch only covers this plugin's one-shot exec channel — see "Known limitations") |
232
242
  | Mutate | start / stop, restart, remove | Ordered by increasing destructiveness; available only with `allowMutations` on, otherwise the whole group is greyed out |
233
243
 
234
244
  Remove gets two extra protections: destructive colouring plus a gap between it and "restart", and a second
@@ -536,8 +546,8 @@ return 400).
536
546
  | `announceToAgent` | true | whether to inject a capability announcement into the agent (systemPrompt section `plugin:dsh-docker`) |
537
547
  | `dockerBin` | `docker` | the docker CLI executable name or path (`podman` works here); only letters, digits and `_ . / \ : -` plus interior spaces are allowed, and it may not start with `-` (**Windows drive letters and `\` must be allowed**, otherwise no absolute path can be entered at all). **"Connect local" probes with exactly this value**; when it is wrong (that CLI does not exist) the message names another container CLI found on `PATH`, but it does **not** rewrite this value for you |
538
548
  | `allowMutations` | false | allows **mutating operations**: container start / stop / restart / remove, image removal / dangling pruning / pulling (the panel buttons and the `docker_action`, `docker_image_remove`, `docker_image_prune`, `docker_image_pull` tools; while off, `/action`, `/images/remove`, `/images/prune`, `/images/pull/stream` return 403 and the corresponding tools are not registered) |
539
- | `allowExec` | false | allows a one-shot `docker exec` (the panel's exec input and the `docker_exec` tool; while off, `/exec` returns 403) |
540
- | — (capability grant) | not granted | `allowMutations` / `allowExec` have **two out-of-band channels**: ① **launch environment variables** (`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`, values `1` / `true` / `yes` / `on`) — resolved from the host's **launch snapshot**, inherited `process` layer only, so writing project `.env` or `~/.dsh/env.yml` does **not** count as a grant; ② **grant in place** (no restart): click the switch in the settings card and run the command it shows in a terminal on the host — effective within seconds, on its own row (one row per capability — the grants are per capability, so side-by-side revoke buttons would not say which one belongs to which), next to a `Granted · YYYY-MM-DD HH:mm:ss` line and a `Revoke host grant` button (a launch-environment grant has no timestamp and cannot be revoked from the UI). In-place grants are **persistent**: they live in `<DSH home>/dsh-kit/capability-grants.json` (0600; the confirmation dir is `<DSH home>/dsh-kit/grant-confirm/`, 0700 — kept inside the kit's own subdirectory, because mechanism names dropped into the shared home root can collide with the harness or another plugin), are read at the next boot, and then **take effect with no further confirmation** — so every boot logs one `elevation: load capability=… via=file grantedAt=…` line per loaded grant. Over HTTP they can always be **turned off** (the emergency brake must not depend on a restart), but setting `true` without a grant is rejected with 400 and both routes spelled out. A `true` in the config is **not** a grant (it lives in the same store HTTP writes to, so the source is indistinguishable). **Upgrade note**: switches opened through the UI before this change become off — set the variable and restart, or grant in place. **Why**: neither the loopback fence nor the same-origin proof stops cross-site pages or in-page scripts (they can just set `Sec-Fetch-Site: same-origin`), and the docker socket is root on the target host; details (including whom this does *not* stop) in [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
549
+ | `allowExec` | false | allows a **one-shot** `docker exec` (the one-off command box on the container detail drawer's **Overview** tab, and the `docker_exec` tool; while off, `/exec` returns 403). **Does not** cover the card's Terminal button (`docker exec -it`), which tty hosts (see "Known limitations") |
550
+ | — (capability grant) | not granted | `allowMutations` / `allowExec` have **two out-of-band channels**: ① **launch environment variables** (`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`, values `1` / `true` / `yes` / `on`) — resolved from the host's **launch snapshot**, inherited `process` layer only, so writing project `.env` or `~/.dsh/env.yml` does **not** count as a grant; ② **grant in place** (no restart): click the switch in the settings card and run the command it shows in a terminal on the host — effective within seconds, on its own row (one row per capability — the grants are per capability, so side-by-side revoke buttons would not say which one belongs to which), next to a `Granted · YYYY-MM-DD HH:mm:ss` line and a `Revoke host grant` button (a launch-environment grant has no timestamp and cannot be revoked from the UI). **Both capabilities can be confirmed with a single paste**: clicking the second switch while the first panel is open no longer replaces it (the two grants are independent anyway) — the panels stack vertically and a **Copy all (N)** button appears at the top of the group, copying the still-pending commands in the order they were started, joined by a **newline**, so one paste in a host terminal runs them all (`bash` / `zsh` / PowerShell / cmd all run a multi-line paste line by line, which is exactly why `&&` — not understood by PowerShell 5.1 — and `;` — not understood by `cmd` — are deliberately **not** used as separators). Once a command expires, Copy all copies only the still-pending ones; when everything is granted (or every panel is collapsed) the button disappears by itself. The two challenges have independent TTLs and probe cycles, so the wording only promises "every switch unlocks by itself once they run" and **never the same instant** — the countdown stays per panel, and each panel's copy / regenerate / collapse keeps acting on its own capability only. In-place grants are **persistent**: they live in `<DSH home>/dsh-kit/capability-grants.json` (0600; the confirmation dir is `<DSH home>/dsh-kit/grant-confirm/`, 0700 — kept inside the kit's own subdirectory, because mechanism names dropped into the shared home root can collide with the harness or another plugin), are read at the next boot, and then **take effect with no further confirmation** — so every boot logs one `elevation: load capability=… via=file grantedAt=…` line per loaded grant. Over HTTP they can always be **turned off** (the emergency brake must not depend on a restart), but setting `true` without a grant is rejected with 400 and both routes spelled out. A `true` in the config is **not** a grant (it lives in the same store HTTP writes to, so the source is indistinguishable). **Upgrade note**: switches opened through the UI before this change become off — set the variable and restart, or grant in place. **Why**: neither the loopback fence nor the same-origin proof stops cross-site pages or in-page scripts (they can just set `Sec-Fetch-Site: same-origin`), and the docker socket is root on the target host; details (including whom this does *not* stop) in [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
541
551
  | `execTimeoutSec` | 30 | default exec timeout in seconds (1–120) |
542
552
  | `pollIntervalSec` | 5 | stats refresh interval for the panel, in seconds (1–60) |
543
553
  | `logTailDefault` | 200 | default log tail line count (1–5000) |
@@ -615,6 +625,18 @@ and reconnect". The record list can be deleted / reset in the settings card (del
615
625
  required, and the error message lists the available target names.
616
626
  - Changing either switch **re-registers the tools immediately**: after turning off `allowMutations` / `allowExec`
617
627
  the corresponding tools (including the three image mutation tools) disappear from the agent side, with no restart.
628
+ - **Registered calls are also subject to the session permission tier gate (tier gate)**: by class — every
629
+ “always registered” tool in the table above is `read` (allowed on every tier); `docker_action` /
630
+ `docker_image_remove` / `docker_image_prune` / `docker_image_pull` are `write`, `docker_exec` is `exec`,
631
+ and these two classes trigger an **approval prompt on every call** under a confined tier (Read Only /
632
+ Workspace Write — the call runs only if the user allows this one; deterministically denied when unattended,
633
+ i.e. approval policy `never`). Full access never prompts, and under Auto review the plugin does not add its
634
+ own prompt (the host’s model pre-review already covers every call). When the host has not composed the
635
+ `sandboxPolicy` / `approval` services the gate is off (behaviour identical to before), and the startup log
636
+ says so: `tier-gate: services absent (…), per-call gate disabled`. This is a **separate layer** from the two
637
+ static switches above: the switches govern registration (host-level, persistent grant), the tier gate governs
638
+ each registered call (per call, follows the tier immediately). Mechanism:
639
+ [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md).
618
640
  - Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs` →
619
641
  `docker_inspect` → `docker_stats`; for image problems `docker_images` →
620
642
  `docker_image_inspect`.
@@ -748,6 +770,11 @@ read-only first:
748
770
  off, `/exec` returns 403 and the `docker_exec` tool is likewise not registered. The two switches are
749
771
  independent and must be turned on explicitly by the user in the settings card. Read-type routes (`/logs/stream`,
750
772
  `/stats/stream`, `/events/stream`, `/images/inspect`) are unaffected by either switch.
773
+ **`allowExec` only governs this plugin's *one-shot* exec channel** (the `/exec` route, the `docker_exec`
774
+ tool, the one-off command box on the container detail drawer's Overview tab) — the card's **Terminal button runs an interactive
775
+ `docker exec -it` shell hosted by tty, which passes through neither switch** and is available even in
776
+ read-only mode (see the boundary note under "Known limitations": it is not a missing gate, it is a
777
+ deliberately kept entry path).
751
778
  2. **Destructive operations restate their consequences**. `remove` maps to `docker rm` (**without `-f`**), and the
752
779
  agent announcement requires confirming the target container with the user before running; a running container
753
780
  errors with a hint that "the container is still running: stop it before removing", never a silent force-delete.
@@ -775,6 +802,11 @@ read-only first:
775
802
  `id` through the `assertRef` allowlist and clamps parameters the same way. Unlike a snapshot, a long stream has no
776
803
  `maxOutputKb` cap (following would lose its point if truncated), and memory protection is shouldered by the
777
804
  client's 5000-line ring buffer and 2000-line colouring cap.
805
+ 9. **Mutating operations get a per-use audit trail**. The five audit lines from kit elevation answer "what was
806
+ authorized"; this answers "what it was used for": every mutation / exec that **passes the capability gate** lands
807
+ one structured `[dsh-docker] capability-use: …` audit line (outlet: the host's stdout), covering both the panel
808
+ routes and the agent tools. The line format, what is *not* logged (403 / tier-gate asks), and the cost that exec
809
+ commands appear alongside the lines are described in the usage-audit entry under "Known limitations".
778
810
 
779
811
  ## Known limitations
780
812
 
@@ -782,6 +814,18 @@ read-only first:
782
814
  without `-i` / `-t`), so it cannot run vim / top / an interactive shell, nor feed stdin for a
783
815
  dialogue. For interactive troubleshooting run `docker exec -it <container> sh` in the tty panel (this works for
784
816
  both local and SSH targets).
817
+ - **`allowExec` is not the gate that keeps you out of the container**: the card's Terminal button
818
+ (`docker exec -it '<container>' sh`) is hosted through tty's `ttyTerminal` and **does not check
819
+ `allowExec`** — it stays available in read-only mode, exactly as the panel's own hint says when exec is
820
+ off ("turn on *Allow exec*, or copy the exec command from the card into the terminal panel to enter the
821
+ container interactively"). This is deliberate: dropping into a container to look at the scene is the most
822
+ common read-only inspection path, and the gate governs **this plugin's own** one-shot channel (`/exec` and
823
+ `docker_exec`: they change local state and can be called in bulk by agent tools), while the interactive
824
+ shell belongs to tty (user-driven; tty is the PTY's owner). **The cost, stated plainly**: once tty is
825
+ installed, `allowExec` was never a boundary against **in-page scripts** (a third-party plugin's client half
826
+ / XSS) anyway — via `ttyTerminal.open` or a WS `spawn` frame they can run arbitrary commands, which is
827
+ strictly stronger than this plugin's one-shot exec. See
828
+ [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) for that layer's threat-model boundary.
785
829
  - **The event stream has a window while disconnected**: `docker events` is a stream of "from now on", so events that
786
830
  happen while the browser is disconnected and reconnecting have already been pushed by the server and are not
787
831
  resent. The client compensates with "do a full list refresh right after a successful reconnect"
@@ -798,16 +842,45 @@ read-only first:
798
842
  A long SSH stream holds that connection in the pool (busy) while other commands on the same host still reuse the
799
843
  same connection without affecting each other. **The stats stream does not end naturally**, so closing it must be
800
844
  the frontend actively aborting the `EventSource`.
845
+ - **The browser's per-origin budget is 4 live streams (this is what really sets the merge cap — D165)**:
846
+ the host speaks **HTTP/1.1** (no h2 multiplexing), a browser allows only so many TCP connections **per origin**, and
847
+ **every `EventSource` owns one connection and never releases it voluntarily**. Once that budget is used up, **every**
848
+ ordinary request from the same page (list refreshes, `/api/*`, session loading…) queues behind it — the user sees the
849
+ **whole DSH page freeze**, not just the Docker panel.
850
+ Measured (2026-10-08, real Chrome + real host; 10 in-page requests, 2s timeout):
851
+
852
+ | Live streams | In-page requests | Out-of-process request (same moment) |
853
+ |---|---|---|
854
+ | 0 | 10/10, median 27ms | 26ms |
855
+ | 4 | 8/8, median 25ms | — |
856
+ | **5** | **0/10, all timed out** | **41ms (fine)** |
857
+
858
+ That last column is the point: **the jam is in the browser, not the host**. The live-stream budget is therefore
859
+ **4**; while the panel sits on the container list the Activity strip's event stream is always open (1 of them), so
860
+ merged logs get **3** — that is where the merge cap (`PICK_MAX = 4 − 1`) comes from, and the selection bar says so.
861
+ **This is a hard constraint, not a performance tip**: going over does not mean "a bit slower", it means every request
862
+ on that page fails together. To reproduce: open `new EventSource('/api/dsh-docker/events/stream?target=<target>')`
863
+ in devtools until the 5th one, then fire an ordinary request and watch it hang; closing them restores it.
864
+ (WebSockets do **not** draw on this budget — the tty terminal's WS coexisted fine with 4 SSE streams, measured.)
801
865
  - **The SSH channel budget is shared per target (`MaxSessions`)**: a target keeps only **one** TCP connection, and
802
866
  every long stream and short command shares that connection's channels — while OpenSSH's `MaxSessions` defaults to
803
- just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel, and merged logs can
804
- take 8 at once, which uses the budget up exactly; the next "refresh list" (a short command) is then rejected by the
867
+ just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel; the next
868
+ "refresh list" (a short command) can then be rejected by the
805
869
  far end with `(SSH) Channel open failure: open failed`. The plugin therefore caps each SSH target at **8 concurrent
806
- long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect), and the merged-log selection
807
- cap on an **SSH target** drops from 8 to 6 (local targets go through subprocesses and are unaffected). Going over
870
+ long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect). Note the **merge cap is set by
871
+ the browser budget above, not by `MaxSessions`** — those are two independent accounts, and an earlier version
872
+ conflated them (it used 6, derived from `MaxSessions`, which was above the measured safe value). Going over
808
873
  the cap, and the far end refusing a channel, both produce pointed messages rather than ssh2's raw text. If your sshd
809
874
  tunes `MaxSessions` (`sshd -T | grep maxsessions`), the current cap is a compile-time constant — file an issue if it
810
875
  needs to follow.
876
+ Two more remedies attach to this (`D150`): short commands **queue per target** (at most 2 concurrent per connection,
877
+ `DSH_DOCKER_SHORT_CHANNELS` overrides, for testing / troubleshooting only), and a full budget **self-heals** by
878
+ dropping and `end()`ing the connection and retrying once on a fresh one. And (`D164`): aborting a long stream now
879
+ **settles it on the spot** instead of waiting for the far end — previously, if the far end never confirmed (the
880
+ `KILL` above being refused, or a silent channel), that stream's `busy` count never came down; after 8 such leaks
881
+ **every** long stream on that target was refused with no self-healing path at all (observed: 248 pinned at 8/8 with
882
+ the Activity strip's Reconnect appearing to do nothing). `run()` had already hit this same trap twice (`D112` /
883
+ `D161`); all three teardown paths now settle themselves.
811
884
  - **Docker CLI version differences**: parsing goes through `--format '{{json .}}'`, and fields come and go between
812
885
  versions; the parser always degrades instead of throwing (for example, a missing `State` has the state derived from
813
886
  `Status`, and health is extracted from `(healthy)` / `(unhealthy)`); with fields missing the corresponding columns
@@ -869,8 +942,24 @@ read-only first:
869
942
  withdrawn, and data routes other than `/config` return 403 (long streams in progress — logs / stats / pulling —
870
943
  are wrapped up immediately too). The route objects themselves are not unloaded; they are blocked by the 403, and
871
944
  `/config` stays readable and writable — the settings card is the way back in, with no restart of `dsh web` needed.
872
- - **Mutating operations have no separate audit log**: only docker's own records and the host `ctx.logger`'s
873
- ordinary output.
945
+ - **The usage audit goes to the host's stdout, and exec commands appear in it**: every mutation / exec that
946
+ **passes the capability gate** (the panel's mutation / exec routes and the agent tools `docker_action` /
947
+ `docker_image_remove` / `docker_image_prune` / `docker_image_pull` / `docker_exec`; 403 rejections and parameter
948
+ validation 400s are not logged — "a use = something that passed the gate") lands one
949
+ `[dsh-docker] capability-use: capability=allowMutations|allowExec source=http|tool action=container.remove|…
950
+ target=… ref=… ok=true|false durationMs=…` line in the same key=value style as kit elevation's authorization
951
+ lines, with `capability` matching those lines directly; the pull progress stream logs a start / end pair (if the
952
+ host dies mid-stream, at least the start line remains). **The outlet is `console.log`** (docker D162: on a real
953
+ host, `ctx.logger` lines were measured to reach neither the host's stdout nor any file, and an audit line whose
954
+ whole point is "findable when something goes wrong" is worthless in an invisible channel; tier-gate's log outlet
955
+ makes the same call) — the terminal running `dsh web` shows them, grep works. Kit elevation's authorization lines share the same stdout outlet
956
+ (kit D14 — both line families on one channel, searchable side by side). **The cost**: the exec command is recorded in the
957
+ trailing `detail` field (together with the exit code, e.g. `detail=code=0 cmd=ls -la /app`), truncated to 200
958
+ characters overall with control characters escaped as `\n` literals — a line meant for humans to read has to
959
+ accept appearing in the terminal-visible log stream, the same trade-off in the positive direction as elevation's
960
+ "nonce never enters the log". A docker error also counts as a use (`ok=false` plus `detail=` with the truncated
961
+ error text); tier-gate asks / denies are not logged (nothing was executed, and the host's approval log already
962
+ covers them). There is no query UI and no separate audit file — the way to search it is the host's stdout.
874
963
 
875
964
  - **The boundaries of cross-target aggregation**: concurrency cap 4; a **90s per-target budget (fallback)**, while
876
965
  each command's own timeout (30s) is the one actually honoured — when a budget fires, the underlying child process /
@@ -990,12 +1079,14 @@ ring buffer / action labels / debounce** (pure logic through the `__events` test
990
1079
  the entry label when the sidebar is collapsed (`data-sidebar-collapsed`).
991
1080
  Verification that needs a real daemon follows the manual checklist below.
992
1081
 
993
- `test/logs-stream.test.ts` (37 cases, run by the root `pnpm test`) covers four layers of the live log stream:
1082
+ `test/logs-stream.test.ts` (case count deliberately not hardcoded — `vitest run`'s output is the single source of truth) covers four layers of the live log stream:
994
1083
  `logsStream`'s argv construction and `assertRef` allowlist, the single-line JSON encapsulation of SSE frames
995
1084
  (newlines / multi-byte), the local stream lifecycle (fake spawn: multi-byte across chunks, the SIGTERM→SIGKILL
996
1085
  ladder, close resolve, spawn error) plus the SSH long stream's busy-count pairing / sweeper skip, and the route
997
1086
  layer's event sequence / heartbeat / silent abort when the client disconnects / uniform wrap-up when the plugin is
998
- disabled — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
1087
+ disabled — plus **the `busy` count must return to zero after a long stream is aborted** (D164: neither a far end that
1088
+ never confirms the channel close nor eight such leaks in a row may pin the target, and chunks arriving after teardown
1089
+ are not delivered) — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
999
1090
  window elapses, `end` still comes after those rows) and **exact skip accounting** (D156: frames dropped while a queued
1000
1091
  `skip` has not been written yet must still be reported — a 46-frame ledger where every frame is either delivered or
1001
1092
  accounted for). The coalescer's own rules (window merge / flush on the size cap /
package/README.md CHANGED
@@ -180,11 +180,20 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
180
180
  被不断重开,既掩盖「它断了」,又白建 SSE。重连成功后补一次全量列表刷新(断线期间
181
181
  列表可能已经落后),daemon 全程没起、流从未连上过的情形同样补——否则活动条绿了而
182
182
  列表还停在错误态,看着像好了其实没好。
183
+ **重连被拒时要说原因**(`D164`):服务端主动拒绝这条流时会发一帧 `event: error`
184
+ 带 `message`(最典型的是长流配额满,见「已知限制」——那句话里写着该怎么办);
185
+ 客户端现在把它**显示在活动条上**(「重连被拒:…」),而不是丢掉。原先只置
186
+ `closed`,于是「拒绝 → 回到原位」与「点了没反应」在界面上完全不可区分,用户只能反复点。
187
+ 重连成功(或重新建流)时这条原因清除。
188
+ 断开时右侧那句提示是**折叠头里的单行小字**,所以刻意写短(2026-10-08 用户反馈
189
+ 「啰嗦又生硬」):状态行左边已经在说「事件流已断开」,这句只说「不会自动恢复,点
190
+ 「重连」」——成因、上限与 `MaxSessions` 那一串写在本页,不塞进界面。
183
191
  - **聚合选择(临时多选聚合日志)**:工具条的 `聚合选择` 进入选择态——每张卡片
184
192
  左侧出现勾选框,点卡片本体变成**勾选 / 取消**(不再进详情;动作条暂时收起,
185
193
  免得一边多选一边误点启停删),工具条与列表之间出现操作条:「已选 N 个容器」+
186
- `聚合日志` + `取消`。`聚合日志` 至少需要 2 个容器;选到 7~8 个时给一条软提示
187
- (浏览器同源并发长连接有限制),超过 **8 个**按钮置灰并提示上限。点 `聚合日志`
194
+ `聚合日志` + `取消`。`聚合日志` 至少需要 2 个容器;选到第 3 个时给一条软提示,
195
+ 超过 **3 个**按钮置灰并提示上限(**上限怎么来的见「已知限制」的浏览器同源连接预算**,
196
+ 操作条上也写着那句说明)。点 `聚合日志`
188
197
  进入聚合视图:直接复用 Compose 项目视图那套聚合日志(每容器一条 `/logs/stream`,
189
198
  按 `[service]` / 容器名前缀混流,带过滤与自动滚动),返回即退出选择态并清空。
190
199
  聚合视图的内容控制与**单容器日志完全对齐**(文本过滤 + 级别门槛 + `⬇ .log` / `⬇ .md`
@@ -195,14 +204,14 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
195
204
  列表刷新后已消失的容器按 id 自动剔除。
196
205
  - **按条件一键选中**(多选态):操作条第二行给出一排条件 chip(`全部可见 / 不健康 /
197
206
  需关注 / 已停止`,以及有勾选后的 `同镜像 / 同项目`),计数**按剩余名额截断**——
198
- chip 上写 8 就真的会选中 8 个;超出上限的数量在 title 与结果提示里如实说明。
207
+ chip 上写 3 就真的会选中 3 个;超出上限的数量在 title 与结果提示里如实说明。
199
208
  条件只在**当前筛选结果**里生效(先搜索/筛状态再一键选中)。
200
209
  - **容器卡片**:与参考布局一致的「标签 + 值」行(镜像 / ID / 端口 / 创建 /
201
210
  compose,值等宽、可省略)+ 一排图标操作按钮,**按「查看 / 变更」两组用竖线分隔**:
202
211
 
203
212
  | 组 | 按钮 | 说明 |
204
213
  | --- | --- | --- |
205
- | 查看 / 进入 | 终端、日志、资源占用 | 不改容器状态,只读模式下也永远可用 |
214
+ | 查看 / 进入 | 终端、日志、资源占用 | 不改容器状态,只读模式下也永远可用。**终端按钮跑 `docker exec -it` 交互式 shell,不受 `allowExec` 门控**(那条开关只管本插件的一次性 exec 通道,见「已知限制」) |
206
215
  | 变更 | 启停、重启、删除 | 按破坏性递增排列;只在 `allowMutations` 开启后可用,未开启时整组置灰 |
207
216
 
208
217
  删除额外做了两点防护:破坏性配色 + 与「重启」之间留出间距,点击后仍需二次确认。
@@ -466,6 +475,14 @@ context、不改任何环境变量;空 `targets`(= 用户没配)的语义
466
475
  所以必须有个名字。**一个能力一行**:开关后面跟这个能力自己的授权状态与**授权时刻**(`已授权 · 2026-09-26 22:31:08`),
467
476
  撤销按钮右对齐——两个能力的授权是分开的,并排放会看不出哪个撤销管哪个能力。
468
477
 
478
+ **两个能力的确认也能一次粘贴搞定**:面板开着时再点另一个开关不会顶掉前一块面板(两个能力的
479
+ 授权本来就互不影响),两块面板**纵向堆叠**,顶部多出一个「复制全部(N 条)」——它把仍 pending 的
480
+ 命令按**发起顺序**用**换行符**连接后一次复制,用户去宿主终端**粘贴一次**即可:`bash` / `zsh` /
481
+ PowerShell / cmd 粘贴多行都逐行执行,所以刻意不用 `&&`(PowerShell 5.1 不认)或 `;`(cmd 不认)
482
+ 连接。某条过期后,合并按钮只复制仍 pending 的那些;全部解锁(或全部收起)后它自己消失。两个
483
+ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后全部开关自动解锁」、**不承诺同一时刻**
484
+ ——倒计时仍是逐面板显示,每块面板的复制 / 重新生成 / 收起也照旧只管自己那一个能力。
485
+
469
486
  为什么显示时刻:带外授权是**持久**的——宿主重启后它**直接生效、不再有任何一次确认**。也就是说
470
487
  「上个月授权的能力今天一开机就开着」这件事不会有任何提示,除非界面与日志说出来。所以宿主启动时会
471
488
  逐条打一行 `[dsh-docker] elevation: load capability=… via=file grantedAt=…`(不含 nonce 与路径),
@@ -501,8 +518,8 @@ context、不改任何环境变量;空 `targets`(= 用户没配)的语义
501
518
  | `announceToAgent` | true | 是否向 agent 注入能力公告(systemPrompt section `plugin:dsh-docker`) |
502
519
  | `dockerBin` | `docker` | docker CLI 可执行名或路径(podman 可填 `podman`);只允许字母、数字与 `_ . / \ : -` 及内部空格,且不能以 `-` 开头(**Windows 盘符与 `\` 必须放行**,否则任何绝对路径都填不进来)。**「连接本机」的探测用的就是它**;填错(CLI 不存在)时那句话会点名 `PATH` 里找到的别的容器 CLI,但**不会**替你改这个值 |
503
520
  | `allowMutations` | false | 允许**变更操作**:容器 start / stop / restart / remove、镜像删除 / dangling 清理 / 拉取(面板按钮与 `docker_action`、`docker_image_remove`、`docker_image_prune`、`docker_image_pull` 工具;关闭时 `/action`、`/images/remove`、`/images/prune`、`/images/pull/stream` 返回 403,对应工具不注册) |
504
- | —(能力授权) | 未授权 | `allowMutations` / `allowExec` 有**两条提权通道**:① **启动环境变量**(`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`,值为 `1` / `true` / `yes` / `on`)——判定源是宿主的**启动环境快照**,只认继承来的 `process` 层:写项目 `.env` 或 `~/.dsh/env.yml` **不算**授权;② **就地提权**(免重启):在设置卡片点开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内生效。HTTP 侧永远可以**关掉**它们(紧急刹车不能依赖重启),但给 `true` 而无授权会被 400 拒绝并说清两条路。配置里的 `true` **不算授权**(它与 HTTP 写进去的值存在同一个存储里,分不出来源)。**升级影响**:升级前靠界面打开的开关会变成关——设环境变量重启,或在卡片里就地确认。**为什么**:回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 `Sec-Fetch-Site: same-origin`),而 docker socket 等价目标主机 root;细节(含拦不住谁)见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
505
- | `allowExec` | false | 允许一次性 `docker exec`(面板 exec 输入与 `docker_exec` 工具;关闭时 `/exec` 返回 403) |
521
+ | —(能力授权) | 未授权 | `allowMutations` / `allowExec` 有**两条提权通道**:① **启动环境变量**(`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`,值为 `1` / `true` / `yes` / `on`)——判定源是宿主的**启动环境快照**,只认继承来的 `process` 层:写项目 `.env` 或 `~/.dsh/env.yml` **不算**授权;② **就地提权**(免重启):在设置卡片点开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内生效;两个能力各点一次后,面板组顶部会给一个「复制全部(N 条)」(仍 pending 的命令按发起顺序换行连接),终端粘贴一次即可全部解锁(各自探测周期内先后生效,不承诺同一时刻)。HTTP 侧永远可以**关掉**它们(紧急刹车不能依赖重启),但给 `true` 而无授权会被 400 拒绝并说清两条路。配置里的 `true` **不算授权**(它与 HTTP 写进去的值存在同一个存储里,分不出来源)。**升级影响**:升级前靠界面打开的开关会变成关——设环境变量重启,或在卡片里就地确认。**为什么**:回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 `Sec-Fetch-Site: same-origin`),而 docker socket 等价目标主机 root;细节(含拦不住谁)见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
522
+ | `allowExec` | false | 允许**一次性** `docker exec`(**容器详情抽屉**「概览」页签里的一次性命令框,与 `docker_exec` 工具;关闭时 `/exec` 返回 403)。**不管**卡片「终端」按钮的 `docker exec -it` 交互式 shell——那条由 tty 承载(见「已知限制」) |
506
523
  | `execTimeoutSec` | 30 | exec 默认超时秒数(1~120) |
507
524
  | `pollIntervalSec` | 5 | 面板统计刷新间隔秒数(1~60) |
508
525
  | `logTailDefault` | 200 | 日志默认尾部行数(1~5000) |
@@ -579,6 +596,15 @@ context、不改任何环境变量;空 `targets`(= 用户没配)的语义
579
596
  列出可用目标名。
580
597
  - 两个开关变化会**立即重注册**工具:关掉 `allowMutations` / `allowExec` 后,
581
598
  对应工具(含三个镜像变更工具)从 agent 侧消失,无需重启。
599
+ - **已注册的调用还受会话权限档位闸(tier gate)约束**:按类分——`read` 类(上表「恒注册」
600
+ 的全部)全档放行;`docker_action` / `docker_image_remove` / `docker_image_prune` /
601
+ `docker_image_pull` 归 `write` 类,`docker_exec` 归 `exec` 类,这两类在受限档(仅可查看 /
602
+ 工作区内修改)下**每次调用弹授权询问**(人允许这一次才执行;无人值守即审批策略 `never`
603
+ 时确定性拒绝),完全权限档零询问,Auto review 档不叠加(宿主的模型预审已在管)。宿主未组合
604
+ `sandboxPolicy` / `approval` 服务时不闸(行为与引入前一致),启动日志打一行
605
+ `tier-gate: services absent (…), per-call gate disabled`。它与上面两个静态开关是**双层**:
606
+ 静态开关管注册(宿主级、持久授权),档位闸管已注册调用的放行(逐调用、随档位即时变)。
607
+ 机制见 [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md)。
582
608
  - 推荐排障顺序:`docker_targets` → `docker_ps` → `docker_logs` →
583
609
  `docker_inspect` → `docker_stats`;镜像排查 `docker_images` →
584
610
  `docker_image_inspect`。
@@ -702,6 +728,10 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
702
728
  未开启时,`/exec` 返回 403,`docker_exec` 工具同样不注册。两个开关互相
703
729
  独立,必须在设置卡片由用户显式打开。读取类路由(`/logs/stream`、
704
730
  `/stats/stream`、`/events/stream`、`/images/inspect`)不受这两个开关影响。
731
+ **`allowExec` 只管本插件的「一次性」exec 通道**(`/exec` 路由、`docker_exec`
732
+ 工具、容器详情抽屉「概览」页签的一次性命令框)——卡片上的**「终端」按钮跑的是 `docker exec -it`
733
+ 交互式 shell,由 tty 承载、不经过这两个开关**,只读模式下也可用(见下面
734
+ 「已知限制」里的边界说明:它不是漏了门,是刻意留的进入通道)。
705
735
  2. **破坏性操作要复述后果**。`remove` 映射为 `docker rm`(**不带 `-f`**),
706
736
  agent 公告要求执行前向用户确认目标容器;运行中容器会报错并附
707
737
  「容器仍在运行:先停止再删除」的提示,不会静默强删。
@@ -726,6 +756,11 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
726
756
  `id` 过 `assertRef` 白名单、参数经同样的夹紧。与快照不同,长流没有
727
757
  `maxOutputKb` 上限(跟随被截断就失去意义),内存防护由客户端的 5000 行
728
758
  环形缓冲与 2000 行着色上限承担。
759
+ 9. **变更操作有逐条使用审计**。kit elevation 的五行审计回答「授权了」,这里回答
760
+ 「用了」:每一次**过了能力闸**的变更 / exec 都落一行 `[dsh-docker] capability-use: …`
761
+ 结构化审计行(出口是宿主 stdout),面板路由与 agent 工具两条入口都覆盖。行格式与
762
+ 不记什么(403 / tier-gate ask)、以及「exec 命令会随行出现」这条代价,见「已知限制」
763
+ 的使用审计条。
729
764
 
730
765
  ## 已知限制
731
766
 
@@ -733,6 +768,16 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
733
768
  不带 `-i` / `-t`),不能跑 vim / top / 交互式 shell,也不能喂 stdin 做
734
769
  对话。交互排障请到 tty 面板执行 `docker exec -it <容器> sh`(本机与 SSH
735
770
  目标都可以)。
771
+ - **`allowExec` 不是「进不了容器」的闸**:卡片「终端」按钮(`docker exec -it
772
+ '<容器>' sh`)经 tty 的 `ttyTerminal` 承载,**不检查 `allowExec`**,只读模式下
773
+ 也照常可用——exec 被关时面板给的提示原文就是这么说的(「打开『允许 exec』,或
774
+ 直接复制卡片上的 exec 命令到终端面板交互式进入容器」)。这是刻意的:只读巡检时
775
+ 进容器看现场是最常用的一条路径,闸门管的是**本插件自己**那条一次性通道
776
+ (`/exec` 与 `docker_exec`:改本地状态、可被 agent 工具批量调用),交互式 shell
777
+ 归 tty(用户主动操作、它自己才是 PTY 的所有者)。**代价要说清**:装上 tty 之后,
778
+ `allowExec` 对**页内脚本**(第三方插件的 client 半体 / XSS)本来就不是边界——它们
779
+ 走 `ttyTerminal.open` 或 WS `spawn` 帧就能跑任意命令,比本插件的一次性 exec 还强。
780
+ 这一层的威胁模型边界见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么)。
736
781
  - **事件流有断线窗口**:`docker events` 是「从现在开始」的推流,浏览器断线重连期间
737
782
  发生的事件服务端已经推过、不会补发。客户端用「重连成功后先做一次全量列表刷新」
738
783
  来补偿(状态对齐,不是把事件补回来);活动条里缺的那几条只能靠刷新后的最终状态
@@ -746,6 +791,26 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
746
791
  SSH 长流会占住连接池中的该连接(busy),同一主机上的其它命令复用同一条连接——
747
792
  但**不是互不影响**:通道额度是共享的,见下一条。**统计流不会自然结束**,关闭必须
748
793
  由前端主动断 `EventSource`。
794
+ - **浏览器的同源连接预算是 4 条常驻流(这是聚合上限的真正依据,D165)**:
795
+ 宿主是 **HTTP/1.1**(没有 h2 多路复用),浏览器对**每个源**只放这么几条 TCP,而
796
+ **每条 `EventSource` 独占一条连接、且不会主动释放**。吃满之后,同一个页面里
797
+ **所有**普通请求(列表刷新、`/api/*`、会话加载……)一起排队——用户看到的是
798
+ **整个 DSH 页面卡住**,不只是 docker 面板。
799
+ 实测(2026-10-08,真 Chrome + 真宿主,页内连打 10 次普通请求、2 秒超时):
800
+
801
+ | 常驻流 | 页内请求 | 进程外请求(同一时刻) |
802
+ |---|---|---|
803
+ | 0 条 | 10/10 成功,中位 27ms | 26ms |
804
+ | 4 条 | 8/8 成功,中位 25ms | — |
805
+ | **5 条** | **0/10 全部超时** | **41ms(正常)** |
806
+
807
+ 最后一列是关键:**堵在浏览器,不在宿主**。所以常驻流预算取 **4 条**;面板停在容器页时
808
+ 活动条的事件流恒开(占 1 条),聚合日志因此只能用 **3 条**——这就是聚合上限
809
+ (`PICK_MAX = 4 − 1`)的由来,操作条上写着同一句说明。
810
+ **这不是「性能建议」而是硬约束**:超了不是「慢一点」,是同页面所有请求一起失效。
811
+ 想自己复核:按上表形状在 devtools 里连开 `new EventSource('/api/dsh-docker/events/stream?target=<目标>')`
812
+ 到第 5 条,再打一次普通请求即可看到它挂住;关掉就恢复。
813
+ (WebSocket **不**占这份预算——tty 终端那条 WS 与 4 条 SSE 共存时页面仍正常,实测过。)
749
814
  - **SSH 目标上的通道额度是共享的(`MaxSessions`)**:一个目标只维持**一条** TCP 连接,
750
815
  所有长流与短命令共用这条连接上的通道,而 OpenSSH 的 `MaxSessions` 默认只有 10。
751
816
  长流(日志 / 统计 / 事件)会一直占到用户关掉面板为止,聚合日志还能一次占 8 条——
@@ -764,7 +829,13 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
764
829
  宿主日志留一行 `通道额度已满:已重建连接重试`。之所以必须重建:这条连接**不会自己
765
830
  恢复**(中止长流时发的 `signal('KILL')` 在部分 sshd 上被拒绝,而 sshd 在子进程仍活着
766
831
  时延迟释放 session 槽)。代价是该连接上正在跟随的流会断开、由面板 SSE 自动重连
767
- (日志流按 `D133` 用 `tail=0` 续尾)。**根治办法在 sshd 一侧**:把那台主机的
832
+ (日志流按 `D133` 用 `tail=0` 续尾);③ **中止长流时插件自己的账当场结清**(`D164`)
833
+ —— `AbortSignal` 触发时除了向远端 `KILL` + 关通道,还会**直接结束这条流**,不再等远端
834
+ 回话。原先只等远端的 `close` 事件:远端不确认(上面那种被拒的 KILL,或通道静默)时,
835
+ 那条流的 `busy` 计数**永远不会减**,累积到 8 之后该目标的每条长流都被配额拒绝,
836
+ 且没有任何自愈路径(现场:248 上恒满 8/8,活动条点「重连」毫无反应)。同一类坑
837
+ `run()` 在 `D112` / `D161` 已经踩过两次,现在三条收尾路径统一成「自己 settle」。
838
+ **根治办法在 sshd 一侧**:把那台主机的
768
839
  `MaxSessions` 调大 —— 例如 `/etc/ssh/sshd_config.d/10-dsh-maxsessions.conf` 写一行
769
840
  `MaxSessions 50`,再 `sshd -t && systemctl reload ssh`(reload 只影响新连接)。
770
841
  - **docker CLI 版本差异**:解析走 `--format '{{json .}}'`,字段随版本增减,
@@ -822,8 +893,22 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
822
893
  数据路由一律 403(进行中的长流——日志 / 统计 / 拉取——也会被立即收尾)。
823
894
  路由对象本身不卸载、靠 403 拦截;`/config` 始终可读写,设置卡片就是重新
824
895
  启用的入口,无需重启 `dsh web`。
825
- - **变更操作无独立审计日志**:只有 docker 自身的记录与宿主 `ctx.logger` 的
826
- 常规输出。
896
+ - **使用审计走宿主 stdout,exec 命令会随行出现**:每一次**过了能力闸**的变更 / exec
897
+ (面板的变更 / exec 路由与 agent 的 `docker_action` / `docker_image_remove` /
898
+ `docker_image_prune` / `docker_image_pull` / `docker_exec`;403 与参数校验 400 不记,
899
+ 「使用 = 过了闸」)落一行 `[dsh-docker] capability-use: capability=allowMutations|allowExec
900
+ source=http|tool action=container.remove|… target=… ref=… ok=true|false durationMs=…`,
901
+ key=value 风格与 kit elevation 的授权行一致,`capability` 与授权行直接对得上号;pull
902
+ 进度流拆 start / end 两行(宿主中途挂掉时至少 start 还在)。**出口是 `console.log`**
903
+ (docker D162:真机实测 `ctx.logger` 的行在 0.2.x 宿主上既不进 stdout 也不落盘,而审计行的
904
+ 意义全在「出问题时翻得到」;tier-gate 的日志出口同此取向)——跑 `dsh web` 的那个终端里
905
+ 就能看到、可 grep。kit elevation 的授权行同走宿主
906
+ stdout(**kit D14**,两类行同通道,可对照检索)。**代价**:exec 的命令会记进行尾 `detail`(连同退出码,
907
+ 如 `detail=code=0 cmd=ls -la /app`),整段截断 200 字符、控制字符转义成 `\n` 等字面量——
908
+ 行是给人看的,就得接受它出现在终端可见的日志流里,与 elevation「nonce 绝不进日志」是
909
+ 同一类取舍的正面。docker 报错也算一次使用(`ok=false` + `detail=` 截断后的错误文案);
910
+ tier-gate 的 ask / deny 不记(没执行不算使用,放行与否由宿主 approval 日志覆盖)。
911
+ 没有查询界面、没有独立审计文件——要检索就翻宿主 stdout。
827
912
 
828
913
  - **跨目标聚合的边界**:并发上限 4;**单目标预算 90s(兜底)**,而每条命令自己的超时
829
914
  (30s)才是被遵守的那个——到点时**底层的子进程 / SSH 通道会被真的取消**(不是只让这一格
@@ -946,7 +1031,7 @@ loopback 403(含三条流路由)、容器名 / 镜像引用注入尝试被
946
1031
  卡片 key 等于命名空间 `docker`、找不到宿主侧边栏时安静降级且卸载可重复调用,ttyConnbar 集成的四条路径(连接簿名命中 / host:port 命中 / 未配置主机不加按钮 / tty 未安装静默跳过),FOLLOW 的 SSE 订阅与「回到底部」交互(静态断言),**镜像详情 / 拉取流 / 删除 / prune 入口**、**统计 FOLLOW + sparkline 钩子**、**Compose 分组与聚合日志**、**「活动」条装配 + 事件环形缓冲 / 动作标签 / 防抖**(纯逻辑经 `__events` 测试缝),以及侧边栏折叠态(`data-sidebar-collapsed`)隐藏入口标签的样式规则。
947
1032
  需要真 daemon 的验证走下面的手工清单。
948
1033
 
949
- `test/logs-stream.test.ts`(37 例,随根 `pnpm test` 跑)覆盖日志实时流的四层:
1034
+ `test/logs-stream.test.ts`(例数不写死,`vitest run` 的输出是唯一真相源)覆盖日志实时流的四层:
950
1035
  `logsStream` 的 argv 构造与 `assertRef` 白名单、SSE 帧的单行 JSON 封装(换行 /
951
1036
  多字节)、本地流生命周期(假 spawn:跨 chunk 多字节、SIGTERM→SIGKILL 阶梯、
952
1037
  close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳过、以及
@@ -954,6 +1039,8 @@ close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳
954
1039
  **`tail=0` 只跟随不补历史**(D133)、**背压不重写已写过的帧**与**积压时丢最旧 + `skip` 通知、
955
1040
  不掐流**(D153)、**skip 账目精确**(D156:skip 排队期间再丢弃,两段缺口都要报——
956
1041
  46 块逐块对账「每一帧要么送达、要么被 skip 记账」)、
1042
+ **中止长流后 `busy` 必须归零**(D164:远端不确认关通道、累积 8 次都不会把目标钉死,
1043
+ 且收尾后的分片不再投递)、
957
1044
  以及**分片合帧**(D151:十个分片只出一个 `line` 帧;未到窗口就收尾时 `end` 仍排在
958
1045
  这些行之后)。合帧器本身的规则(窗口合并 / 攒满即推 / flush 幂等 / dispose 停表)在
959
1046
  `test/sse-coalesce.test.ts`(6 例)。
@@ -1595,6 +1595,15 @@
1595
1595
  .dk_chipQuiet { border-color: var(--dk-border); color: var(--dk-label-3); }
1596
1596
  .dk_pickNotice { margin-left: auto; }
1597
1597
 
1598
+ /*
1599
+ * 上限的来由(D165):它是浏览器同源连接预算量出来的,不是随手定的数字。
1600
+ * 与 notice 共用行尾——notice 是「刚发生的事」(选择结果),排在更靠右的位置。
1601
+ */
1602
+ .dk_pickBudget { color: var(--dk-label-3); }
1603
+ /* 只有它一个时贴右,与 notice 同位置(两个都在时 notice 更靠右) */
1604
+ .dk_pickBudget:last-child { margin-left: auto; }
1605
+ .dk_pickBudget + .dk_pickNotice { margin-left: var(--dk-gap-md); }
1606
+
1598
1607
  .dk_pickCount {
1599
1608
  font-size: 12px;
1600
1609
  line-height: 18px;
@@ -2231,7 +2240,24 @@
2231
2240
  /*
2232
2241
  * 就地授权面板:一段「带外确认」的说明 + 一条要复制到宿主终端的命令。
2233
2242
  * 左侧色条把它与普通设置项区分开——它不是配置项,是一个**动作区**。
2243
+ *
2244
+ * 面板组(`dk_elevPanels`):两个能力可以同时 pending(面板开着时再点另一个开关),
2245
+ * 于是多块面板**纵向堆叠**、顶部出一次「复制全部」。最小限度的间距规则,不做视觉重设计。
2234
2246
  */
2247
+ .dk_elevPanels {
2248
+ display: grid;
2249
+ grid-template-columns: minmax(0, 1fr);
2250
+ min-width: 0;
2251
+ gap: var(--dk-gap-md);
2252
+ }
2253
+ /* 合并复制行:主按钮 + 一句说明并排,窄栏下允许换行(说明文案中英都较长) */
2254
+ .dk_elevMerge {
2255
+ display: flex;
2256
+ flex-wrap: wrap;
2257
+ align-items: center;
2258
+ gap: var(--dk-gap-sm);
2259
+ min-width: 0;
2260
+ }
2235
2261
  .dk_elevPanel {
2236
2262
  display: grid;
2237
2263
  /*