@hyzyn/dsh-docker 0.9.4 → 0.9.6

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
@@ -7,6 +7,7 @@
7
7
  ## Features
8
8
 
9
9
  - **A resident session right-sidebar tab**: the panel lives as a session right-sidebar tab (`sidebar.right.pane.tab`) **side by side** with the conversation — after handing an error to the agent the logs stay on the right, without getting in the way of watching it work; collapsing it leaves the viewport. Panel-level state (target / view / filter text / selected container and its tab) is kept **across sessions**, and collapsing drops the live streams to hand the SSH channels back. Hosts without the right-sidebar services fall back to the original dock / modal, behaving exactly as before.
10
+ - **An explicit one-click "connect local"**: two **non-permanent** entries — with no targets at all it is the primary button in the "no Docker targets configured yet" empty state, and once you have other targets it is the **last item in the target dropdown**, `+ Connect local` (it is deliberately **not** on the toolbar, where being permanent means occupying space and begging for clicks — pure noise) — one click (or picking that item) adds the machine hosting the plugin as a local target and selects it (an existing local target is reused, never duplicated; SSH and custom targets stay untouched), and the same operation is `docker_connect_local` on the agent side. It does **not** change the meaning of an empty `targets`, never invents an implicit default target, never installs Docker / starts a daemon / switches docker contexts, and needs no capability grant (it only adds one local target pointing at the plugin's own host).
10
11
  - **Aggregated fetches across targets**: the Overview page fans out over every `targets[]` entry in parallel, and an unreachable target only spoils its own cell; the agent side exposes the same shape through `docker_ps target:"*"` / `docker_attention target:"*"`, so targets never block one another.
11
12
  - **"Needs attention" reads authoritative fields**: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / dead; OOM and the real exit code come from one `docker inspect` — the 137 in a `docker ps` summary cannot separate an OOM kill from a manual kill, so filtering on the summary alone must misreport.
12
13
  - **Four long-lived SSE streams on one substrate**: log FOLLOW, `docker stats`, `docker events` and `docker pull` all run through the same `openSseStream` (heartbeat / active-stream registry / teardown on disconnect) and differ only in how they end — logs and pulls finish on their own, stats and events are aborted by the browser. Multi-select merged logs recover true cross-container ordering from the `--timestamps` prefix; "pause" freezes rendering only (the stream keeps receiving and flushes in one batch on resume).
@@ -191,6 +192,15 @@ Inside the panel:
191
192
  health_status / destroy / rename / update): noise such as `exec_*` and `archive-path` (`docker cp`)
192
193
  is dropped server-side — on one batch machine 47 events over 24 hours were all exec with 0 allowlist hits, so on
193
194
  machines that are only ever exec'd the Activity strip is empty, and that is deliberate.
195
+ A closed stream needs a **manual reconnect**: entering `closed` is terminal (EventSource only self-heals
196
+ connection blips; the `docker events` process exiting or the server sending an `end` frame both close it), so the
197
+ Activity strip offers a **Reconnect** button (shown only while `closed`; not in `unsupported` environments, where
198
+ reconnecting would just fail again), and the toolbar's ⟳ reconnects as well. The **AUTO REFRESH poll deliberately
199
+ does not reconnect**: folding it into the poll would reopen a terminated stream forever, both hiding that it broke
200
+ and pointlessly creating SSE connections. A successful reconnect compensates with one full list refetch (the list
201
+ may have fallen behind while the stream was down), including the case where the daemon never came up and the
202
+ stream never opened at all — otherwise the strip turns green while the list still shows its error state, looking
203
+ fixed when it is not.
194
204
  - **Select for merging (temporary multi-select merged logs)**: the toolbar's `Select for merging` enters selection
195
205
  mode — a checkbox appears on the left of every card, clicking a card body becomes **select / deselect** (it no
196
206
  longer opens details; the action bar collapses temporarily so that multi-selecting does not mis-click
@@ -377,6 +387,70 @@ must satisfy: the docker CLI is installed, and the current account can use docke
377
387
  **without sudo** (usually because it is in the `docker` group); otherwise `probe` passes through errors such as
378
388
  `permission denied while trying to connect to the Docker daemon socket` verbatim.
379
389
 
390
+ #### Connect local (one click)
391
+
392
+ If you would rather not fill in a target by hand, the UI offers two entries, neither of them
393
+ permanent:
394
+
395
+ | When | Where |
396
+ | --- | --- |
397
+ | You have no targets at all | A **Connect local** primary button in the panel's empty state (the whole panel is blank then, so one click is the shortest path) |
398
+ | You already have other targets | The **last item in the target dropdown**, `+ Connect local` — right where you look for a machine, and only visible once you open it |
399
+
400
+ The two are mutually exclusive (the same entry never appears twice): with no targets you use the big
401
+ button in the empty state; with targets, the dropdown carries the item. **Once a local target exists
402
+ neither appears** (it is already in the selector, so listing it again would just be a duplicate).
403
+
404
+ On the agent side the same operation is `docker_connect_local`, and over HTTP it is
405
+ `POST /connect-local` — every entry point shares one implementation on the host.
406
+
407
+ Why not a permanent toolbar button: a permanent "Connect local" both occupies the spot right of the
408
+ target selector and has to attract clicks with an accent colour, while being pure noise **once you
409
+ have a usable target** (the first version was permanent, and the user called it out as redundant on
410
+ sight). But keeping only the empty-state entry would miss the common case of "I have a few SSH hosts
411
+ and want to add this machine too" — so the second entry lives in the dropdown, appearing only when
412
+ you are genuinely looking for a machine.
413
+
414
+ | Situation | Behavior |
415
+ | --- | --- |
416
+ | A `kind=local` target already exists (whatever its name) | **Reuse it** (`created:false`) and just switch to it; nothing is created, rewritten or renamed |
417
+ | No local target | Append one under the stable name `local`; if `local` is taken by another target, try `local-2`, `local-3`, … (deterministic, conflict-free, never overwriting) |
418
+ | SSH / custom targets configured | **Left exactly as they are** (order and credentials included); only the local entry is appended at the end |
419
+ | Clicked twice / concurrently | Idempotent: exactly one local target in the end |
420
+ | Settings store unavailable / save fails | A clear error (503 / 500 with the reason) and **no in-memory config change** — never "it showed up in the UI but is gone after a refresh" |
421
+
422
+ After the write succeeds it also runs one **read-only probe** (`docker version`) and puts the result
423
+ into `message`: the server version when the daemon is reachable, or the actual cause when it is not —
424
+ **docker CLI missing** (install Docker, or point "docker CLI" at the right path) / **probe timed
425
+ out** (the daemon is wedged, or `docker context` points at an unreachable remote; check with
426
+ `docker context ls`) / **daemon not running** (Linux: `systemctl start docker`; macOS / Windows:
427
+ start Docker Desktop) / **no permission on the socket** (add the account to the `docker` group) —
428
+ instead of just "no containers". A failed probe is **not** a failed operation (the target really was
429
+ added; `reachable:false` says so separately), and it works as soon as the daemon comes up.
430
+
431
+ The binary this probe uses is **exactly the "docker CLI" value from the settings card** (`dockerBin`,
432
+ `docker` by default). When that CLI is missing it does one **read-only** lookup for another container
433
+ CLI on `PATH` (`docker` → `podman` → `nerdctl`) and names it in the message ("this machine has
434
+ podman: put podman in 'docker CLI' and it works") — it **only tells you, and never rewrites your
435
+ config**: silently changing `dockerBin` would contradict this plugin's refusal to decide on your
436
+ behalf, and podman's output-format compatibility with this plugin has not been verified item by item
437
+ (see "known limitations"). Candidate matching is by **tool name** (basename), not by the raw string
438
+ in the config, so a `dockerBin` of `/usr/local/bin/docker` does not produce the nonsense "change
439
+ docker to docker".
440
+
441
+ Timeouts are a **separate case**: `probe()` does not just report an exit code (`code === null` makes
442
+ "exit code null" carry zero information), it returns `timedOut:true` plus an actionable sentence. It
443
+ also **actually returns on time** — SIGKILL only reaches the direct child, so when `dockerBin` points
444
+ at a wrapper script the grandchild keeps holding the pipes and stretches the 15-second cap into
445
+ minutes (measured: a 3-second cap took 60 seconds), so on expiry it kills, allows a 500 ms settle
446
+ window, and then releases the pipes itself if `close` still has not arrived.
447
+
448
+ Deliberately **not** done (the conservative boundary): installing Docker, starting a daemon,
449
+ switching docker contexts, or touching any environment variable; an empty `targets` (= the user has
450
+ none) keeps its meaning, with no implicit default target; and the `clearTargets` protection against
451
+ accidental clearing is untouched. The path is also **independent of the capability switches** — it
452
+ only writes the plugin's own configuration and involves no target-side write that would need a grant.
453
+
380
454
  ### Merged logs (multi-select / Compose project)
381
455
 
382
456
  Selecting several containers or opening a Compose project can both merge the logs of several containers into one
@@ -460,7 +534,7 @@ return 400).
460
534
  | --- | --- | --- |
461
535
  | `enabled` | true | disables the whole plugin (**takes effect on save**: tools are unregistered immediately, the announcement is withdrawn, and all data routes except `/config` return 403; `/config` stays readable and writable — the settings card is the way back in. Unlike tty, which needs a restart) |
462
536
  | `announceToAgent` | true | whether to inject a capability announcement into the agent (systemPrompt section `plugin:dsh-docker`) |
463
- | `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) |
537
+ | `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 |
464
538
  | `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) |
465
539
  | `allowExec` | false | allows a one-shot `docker exec` (the panel's exec input and the `docker_exec` tool; while off, `/exec` returns 403) |
466
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-一条请求经过什么) |
@@ -520,6 +594,7 @@ and reconnect". The record list can be deleted / reset in the settings card (del
520
594
  | Tool | Registration | Parameters | Purpose / typical use |
521
595
  | --- | --- | --- | --- |
522
596
  | `docker_targets` | always registered | `probe?: boolean` | lists targets (name / kind / label); `probe:true` probes the docker version and daemon reachability for each one (SSH targets open connections, so it is slower). Other tools take their `target` from here |
597
+ | `docker_connect_local` | always registered | — (no parameters) | **Connect local**: makes sure a `kind=local` target exists and selects it (an existing one is reused, never duplicated; SSH / custom targets stay untouched), then probes the daemon read-only: returns `{name, kind, created, saved, reachable, serverVersion?}`. When nothing is configured yet `docker_ps` and friends report "no Docker targets configured" — this is the one step that fixes it. **Requires no capability grant** (it only writes the plugin's own config plus a read-only probe) and never installs Docker / starts a daemon / switches context / touches environment variables. When the daemon is unreachable the result text names the cause (CLI missing / probe timed out / daemon not running / no socket permission; when the CLI is missing it also names another container CLI found on PATH, but never rewrites dockerBin for you) instead of an empty list |
523
598
  | `docker_ps` | always registered | `target?` (**pass `*` = all targets**), `all?: boolean` | lists containers (name / state / health / image / ports / compose project and service / short ID); by default only running ones, `all:true` includes stopped. With `target:'*'` it returns results grouped by target, and **one unreachable target does not affect the others** (that group carries `error`). Ports are merged across the IPv4/IPv6 dual-stack expansion (one `-p` no longer shows twice, D130); an empty `ports` does **not** mean "nothing exposed" — host-network containers publish on the host itself, and that case now carries a `net` field (D131). The first step of troubleshooting |
524
599
  | `docker_attention` | always registered | `target?` (supports `*`), `limit?: number` | a **needs-attention summary**: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / zombie; every item carries `reasons`, `exitCode`, `oomKilled`, `restartCount`. OOM and the real exit code come from one `docker inspect` (a ps summary cannot distinguish a manual kill from 137). The troubleshooting entry point: call it first when you are unsure which machine or container to look at |
525
600
  | `docker_inspect` | always registered | `target?`, `id` (required) | `docker inspect`'s authoritative details: state / health check / exit code / restart count / ports / mounts / networks / startup command |
@@ -556,8 +631,8 @@ loopback, `localhost`, or a hostname / `/etc/hosts` alias that **resolves to thi
556
631
  `referrer-policy: no-referrer`.
557
632
 
558
633
  **On top of loopback there is a second gate, the "same-origin proof"**: the four SSE streams (`/logs/stream`,
559
- `/stats/stream`, `/events/stream`, `/images/pull/stream`) and eight mutating sub-routes (`/action`,
560
- `/images/remove|prune`, `/networks/remove|prune`, `/volumes/remove|prune`, `/exec`) require either
634
+ `/stats/stream`, `/events/stream`, `/images/pull/stream`) and nine write sub-routes (`/action`,
635
+ `/images/remove|prune`, `/networks/remove|prune`, `/volumes/remove|prune`, `/exec`, `/connect-local`) require either
561
636
  `Origin: <same origin>` or `Sec-Fetch-Site: same-origin`, otherwise 403 `缺少同源证明` (missing same-origin
562
637
  proof). This blocks cross-site side effects such as a malicious page using
563
638
  `<img src=.../images/pull/stream>` to trigger a real pull; curl, old Safari and some WebViews do not send those
@@ -579,6 +654,7 @@ returned 403 on Desktop — the symptom was the logs / stats / events / pull str
579
654
  | `/config` | GET | — | `{ok:true, config}`: the config snapshot (targets expose only `passwordSet` / `passphraseSet`, plus the read-only `ttyBooks` / `ttyAvailable` / `toolsRegistered`) |
580
655
  | `/config` | POST | any subset of the config keys above | `{ok:true, config}`; an unknown key is 400 and invalid JSON is 400 |
581
656
  | `/targets` | GET / POST | — | `{ok:true, targets:[{name, kind, label?\|error?}]}` |
657
+ | `/connect-local` | POST | `{}` (the body is ignored) | **Connect local**: reuses or creates a `kind=local` target and returns `{ok:true, result:{name, kind, created, saved, reachable, serverVersion?, message}, config}`; 503 when the host has no settings service and 500 when the save fails (both carry a reason and change no in-memory config). Requires the same-origin proof (it is a write path) but **not** `allowMutations` / `allowExec` |
582
658
  | `/probe` | POST | `{target?}` | `{ok:true, probe:{ok, bin, serverVersion, error, target}}` |
583
659
  | `/containers` | POST | `{target?, all?}` | `{ok:true, containers: ContainerSummary[]}`; with `target:'*'` it returns `{ok:true, groups:[{target,label,ok,error?,data?}]}` (concurrent cross-target aggregation) |
584
660
  | `/attention` | POST | `{target?, limit?}` | for a single target `{ok:true, items: AttentionItem[], total, truncated, degraded}` (`limit` applies **after** filtering + severity sorting; default 100, max 500); with `target:'*'` `{ok:true, groups:[{target,label,ok,error?,data:{items,total,truncated,degraded}}]}` |
@@ -796,10 +872,12 @@ read-only first:
796
872
  - **Mutating operations have no separate audit log**: only docker's own records and the host `ctx.logger`'s
797
873
  ordinary output.
798
874
 
799
- - **The boundaries of cross-target aggregation**: concurrency cap 4, per-target timeout 45s; a single target failing
800
- or timing out affects only its own cell (the group carries `error`). With many targets the Overview's request
801
- volume grows linearly with the target count (2 requests per target), and auto refresh multiplies that volume —
802
- with many targets it is best to turn auto refresh off.
875
+ - **The boundaries of cross-target aggregation**: concurrency cap 4; a **90s per-target budget (fallback)**, while
876
+ each command's own timeout (30s) is the one actually honoured — when a budget fires, the underlying child process /
877
+ SSH channel is **really cancelled** (not merely reported as a failed cell), otherwise the timed-out command keeps
878
+ holding a remote session slot (D161). A single target failing or timing out affects only its own cell (the group
879
+ carries `error`). With many targets the Overview's request volume grows linearly with the target count (2 requests
880
+ per target), and auto refresh multiplies that volume — with many targets it is best to turn auto refresh off.
803
881
 
804
882
  ## How it works
805
883
 
@@ -889,7 +967,7 @@ injection)**, `assertBin`, `formatBytes`, `shJoin` escaping, `DockerApi`'s argv
889
967
 
890
968
  `scripts/route-smoke.mjs` runs end to end with **a fake cordis ctx + a fake docker CLI script**: plugin
891
969
  mounting (settings / tools / routes / capability-announcement registration), the actual calls and returns of
892
- **26 routes** (including the event sequences and parameter validation of the four SSE streams `/logs/stream`,
970
+ **27 routes** (including the event sequences and parameter validation of the four SSE streams `/logs/stream`,
893
971
  `/stats/stream`, `/events/stream`, `/images/pull/stream`, with the event stream additionally asserting that noise is
894
972
  dropped by the allowlist), the `docker_events` tool's snapshot output and its `since` character-set validation,
895
973
  `/images/inspect`'s details + build history, `/config` credential masking, 400 for unknown config keys, 403 for
@@ -977,15 +1055,30 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
977
1055
  details / logs work; the first connection logs "host key fingerprint recorded (TOFU)" and the second does not
978
1056
  prompt again; after manually changing the fingerprint in `hostKeys` and reconnecting, the connection should
979
1057
  **be rejected** with reset guidance.
980
- 3. **Read-only interception**: with both switches off, `/action`, `/exec`, `/images/remove`,
981
- `/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly 11 read-only tools are
982
- registered (`docker_targets` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` / `images` /
1058
+ 3. **Connect local (one click)** — exercise **both** entries:
1059
+ - **With no targets**: clear every target and save (or use a fresh profile), open the panel — the
1060
+ empty state in the body shows a "Connect local" primary button, and the dropdown should **not**
1061
+ list it again; one click should produce a `kind=local` target named `local`, select it
1062
+ immediately, and load the container list;
1063
+ - **With other targets**: keep a few SSH hosts and open the target dropdown — the last item is
1064
+ `+ Connect local`, and the toolbar must carry **no** permanent entry; picking it should likewise
1065
+ add `local` and switch to it, after which the item disappears from the dropdown (a local target
1066
+ now exists).
1067
+ Neither place may show "the same entry listed twice"; nothing should install Docker, start a daemon
1068
+ or switch contexts along the way. On a host without docker you should see a named cause (CLI
1069
+ missing / probe timed out / daemon not running / no socket permission) rather than an empty list.
1070
+ - **On a machine that only has podman** (drop docker from `PATH`, leave podman): that sentence
1071
+ should **name podman** and say "put podman in 'docker CLI' and it works", while **not**
1072
+ rewriting `dockerBin` for you (go back to the settings card — the value is still `docker`).
1073
+ 4. **Read-only interception**: with both switches off, `/action`, `/exec`, `/images/remove`,
1074
+ `/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly 12 read-only tools are
1075
+ registered (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` / `images` /
983
1076
  `image_inspect` / `networks` / `volumes` — see the tool table for the authoritative list), and
984
1077
  the panel's start / stop / remove, image removal, pruning and pull buttons are greyed out. After turning on
985
1078
  "allow mutations" these routes and tools appear immediately (no restart needed).
986
- 4. **Logs / stats / images**: `tail` and `timestamps` / `since` take effect; stats show
1079
+ 5. **Logs / stats / images**: `tail` and `timestamps` / `since` take effect; stats show
987
1080
  CPU, memory, network and block IO; the image list carries a marker on dangling entries.
988
- 5. **FOLLOW live log stream**: turning on `FOLLOW` on the log page → the status line first says
1081
+ 6. **FOLLOW live log stream**: turning on `FOLLOW` on the log page → the status line first says
989
1082
  "connecting" and then "following live", and new lines from `docker logs -f` appear immediately (`docker run --rm alpine sh
990
1083
  -c 'i=0; while :; do echo line-$i; i=$((i+1)); sleep 1; done'` makes this observable);
991
1084
  with FOLLOW on, `AUTO REFRESH` is greyed out and polling stops; scrolling up brings up "back to bottom",
@@ -996,26 +1089,26 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
996
1089
  itself, with no error banner. Run the same with an SSH target and confirm that panel operations such as
997
1090
  `docker ps` on the same host are unaffected while the stream runs (connection reuse), and that idle reclamation
998
1091
  (120s) does not cut the stream.
999
- 6. **Stats live following**: turning on `FOLLOW` on the stats page → "connecting to the stats stream…" → "following
1092
+ 7. **Stats live following**: turning on `FOLLOW` on the stats page → "connecting to the stats stream…" → "following
1000
1093
  live (docker stats)", and the CPU / memory sparklines grow a little every second (run `docker run --rm
1001
1094
  alpine sh -c 'while :; do :; done'` to watch CPU rise); turning FOLLOW off immediately returns to snapshots and
1002
1095
  resumes polling. Stop the containers being measured → the stream receives `end` (stats-exit), hints, and then
1003
1096
  returns to polling automatically.
1004
1097
  Note that **this stream does not end naturally**: switching pages / closing the panel must close the `EventSource`
1005
1098
  (on the host side `docker stats` should be seen being SIGTERM'd).
1006
- 7. **Image details / removal / pruning**: click "details" on a row of the images page → the layer count in the
1099
+ 8. **Image details / removal / pruning**: click "details" on a row of the images page → the layer count in the
1007
1100
  overview matches `docker image inspect` and the build history matches `docker history` (Docker ≥ 26
1008
1101
  goes through `--format`, older versions fall back to the plain-text table); look up a dangling row by image ID.
1009
1102
  Click "remove" → after a second confirmation it runs `docker image rm`; removing an image that a container
1010
1103
  references should fail with a hint.
1011
1104
  "prune dangling" only removes untagged images, and its output ends with `Total reclaimed space`.
1012
- 8. **Pull progress stream**: the pull icon in the images toolbar (hovering shows "pull image") → enter a small image
1105
+ 9. **Pull progress stream**: the pull icon in the images toolbar (hovering shows "pull image") → enter a small image
1013
1106
  you do not have locally (such as `alpine:3.20`)
1014
1107
  → per-layer status lines appear live and are updated in place by layer; when it finishes it hints "pull complete"
1015
1108
  and refreshes the list automatically.
1016
1109
  Clicking "stop" mid-pull or leaving the view → `docker pull` is terminated by SIGTERM with no leftover process.
1017
1110
  With "allow mutations" off the button is greyed out, and going straight to `/images/pull/stream` returns 403.
1018
- 9. **Compose project view**: start two or three services with a compose file (`docker compose up -d`) →
1111
+ 10. **Compose project view**: start two or three services with a compose file (`docker compose up -d`) →
1019
1112
  switch the toolbar to "Compose" → the services are grouped under one project card with the correct
1020
1113
  running / service counts and states;
1021
1114
  click into the project to see the service table, switch to "merged logs" → the services' logs appear mixed by the
@@ -1023,12 +1116,12 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1023
1116
  content; after turning "auto
1024
1117
  scroll" off new logs keep entering the buffer without the view jumping. Containers without a compose label are
1025
1118
  grouped under "other containers (non-compose)".
1026
- 10. **After turning on `allowMutations`**: stop / start / restart succeed; removing a running container
1119
+ 11. **After turning on `allowMutations`**: stop / start / restart succeed; removing a running container
1027
1120
  errors with a "stop it before removing" hint, and stopping first and then removing succeeds.
1028
- 11. **After turning on `allowExec`**: a command such as `ls -la /app` returns stdout and the exit code; changing the
1121
+ 12. **After turning on `allowExec`**: a command such as `ls -la /app` returns stdout and the exit code; changing the
1029
1122
  command to `sleep 60` (with `timeoutSec` lowered) should be interrupted and report a timeout; a `command` longer
1030
1123
  than 8000 characters is rejected.
1031
- 12. **The exec drawer coexists with the body** (tty ≥ 0.15): after opening the drawer from a card's "Terminal", switch
1124
+ 13. **The exec drawer coexists with the body** (tty ≥ 0.15): after opening the drawer from a card's "Terminal", switch
1032
1125
  to logs / stats / another container's details, and both the drawer and the terminal session must stay alive;
1033
1126
  **collapsing** (the arrow or double-clicking the top edge) squashes the drawer into one title bar and the session
1034
1127
  keeps running (expanding restores it); **dragging the top edge** changes the height without exceeding 75% of the
@@ -1037,7 +1130,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1037
1130
  Offline regression: `node packages/tty/scripts/preview.mjs docker-exec-logs`
1038
1131
  (headless Chrome runs the real client: drawer + log page + collapse and expand, asserting that the session was
1039
1132
  not ended).
1040
- 13. **Entering the container panel from the connection bar (dock mode, needs tty ≥ 0.16)**: open an SSH tab in the
1133
+ 14. **Entering the container panel from the connection bar (dock mode, needs tty ≥ 0.16)**: open an SSH tab in the
1041
1134
  tty panel →
1042
1135
  connection bar "Containers" → the container panel should dock **to the right of the terminal** (not a full-screen
1043
1136
  modal) while the terminal stays typable;
@@ -1047,7 +1140,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1047
1140
  `<container> · exec` tab in the same terminal panel** rather than nesting another terminal drawer.
1048
1141
  Offline regression: `node packages/tty/scripts/preview.mjs docker-dock`
1049
1142
  (asserting docked / no backdrop / the terminal widens after collapsing / the container panel survives).
1050
- 14. **Multi-select merged logs**: click `Select for merging` in the container list → checkboxes appear on the left of
1143
+ 15. **Multi-select merged logs**: click `Select for merging` in the container list → checkboxes appear on the left of
1051
1144
  the cards and the card action bars collapse;
1052
1145
  check 2–3 containers → the action bar shows "3 containers selected", and clicking `merged logs` → the merged
1053
1146
  view's title is
@@ -1055,11 +1148,11 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1055
1148
  checked the button is greyed out and hints "select at least 2 containers", and at 9 checked it is greyed out and
1056
1149
  hints at a maximum of 8; stop one of the containers from the list → that stream ends with the usual `end`
1057
1150
  semantics while the others are unaffected.
1058
- 15. **Esc leaves selection mode**: pressing Esc in selection mode → back to the normal list, checks cleared and the
1151
+ 16. **Esc leaves selection mode**: pressing Esc in selection mode → back to the normal list, checks cleared and the
1059
1152
  action bar gone;
1060
1153
  clicking `Select for merging` again (its label is now `Exit selection`) has the same effect; switching targets /
1061
1154
  switching segments / closing the panel also clear the selection mode and the checks together.
1062
- 16. **The Activity strip**: the "Activity" strip appears at the head of the container list, its status dot goes from
1155
+ 17. **The Activity strip**: the "Activity" strip appears at the head of the container list, its status dot goes from
1063
1156
  yellow to green and the copy reads "receiving live
1064
1157
  (docker events)"; running `docker restart <container>` / `docker stop`+`start` → the
1065
1158
  list state follows within 1 second and the Activity strip shows `stop` / `start` (abnormal exits show the coded
@@ -1069,7 +1162,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1069
1162
  server-side allowlist);
1070
1163
  after the network drops / `dsh web` restarts, recovery briefly shows a yellow status dot and automatically does
1071
1164
  one full list refresh.
1072
- 17. **Networks / volumes**: the toolbar segments show "Networks" and "Volumes". The network list should contain
1165
+ 18. **Networks / volumes**: the toolbar segments show "Networks" and "Volumes". The network list should contain
1073
1166
  `bridge` / `host` / `none`
1074
1167
  plus project networks created by compose; click one to enter details → the subnet / gateway in the overview match
1075
1168
  `docker network
@@ -1082,7 +1175,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1082
1175
  volume pruning raises a confirmation saying "the data will be deleted as well". **Note**: on docker < 23 volume
1083
1176
  pruning also deletes named volumes, so it is best to confirm on a test target first.
1084
1177
 
1085
- 18. **Multi-target Overview**: configure two or more targets (local + one SSH) → the `Overview` pill appears next to
1178
+ 19. **Multi-target Overview**: configure two or more targets (local + one SSH) → the `Overview` pill appears next to
1086
1179
  the target picker;
1087
1180
  clicking it shows two counter cards on one screen (each badged "local" / "SSH") plus the attention area, with the
1088
1181
  card numbers matching that target's
@@ -1096,7 +1189,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1096
1189
  "all good"; while targets are still answering it shows "reading…". Turning on "auto refresh" → polls all targets
1097
1190
  at
1098
1191
  `pollIntervalSec` (turning it off stops that).
1099
- 19. **Switching targets does not cross-talk (the list write gate)**: let Target1 (an unreachable SSH host) fail first
1192
+ 20. **Switching targets does not cross-talk (the list write gate)**: let Target1 (an unreachable SSH host) fail first
1100
1193
  and raise an "operation failed" banner,
1101
1194
  then immediately switch to the healthy Target2 → the banner should **disappear with the switch at once** and the
1102
1195
  list should be Target2's containers; then wait 20s
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  - **常驻会话右侧栏**:面板作为会话右侧栏标签(`sidebar.right.pane.tab`)与对话**同屏**——发完错误交给 Agent 后日志留在右边,不影响看它干活;折叠即退出视野、不占屏。面板级状态(目标 / 视图 / 过滤词 / 选中容器及它的页签)**跨会话保留**,而折叠会主动断流、把 SSH 通道还回去。拿不到右侧栏服务的老宿主自动退回原有的 dock / 模态,行为与旧版一致。
10
10
  - **日志右键交给 Agent**:日志页拖选报错行 → 右键「直接发送到当前会话 / 填入输入框,我先改改」,把「选中的行 + 目标 / 容器 / 时间窗 + 前后各 20 行上下文」交给会话(内容会进模型上下文,菜单里明写了留意凭证)。投递成功有两处回执:**视口级 toast**(挂 `body`,盖过面板与终端弹窗),以及——终端面板正开着时——**自动折起终端**(`ttyPanel` 契约 v2 的 `minimize()`;会话继续跑,恢复靠侧边栏「终端」入口的徽标),让会话直接露出来。老版本 tty 没有这个能力时退化成 toast 里「会话在面板后面」的指引。需要改稿就先「填入输入框」——**编辑面只有会话输入框一个**(它多行、能看到完整上下文,Agent 收到的就是它),不再另开一张更弱的卡片编辑器。目标会话的定位跨宿主版本:≤0.1.5 读 `sessions.list` 快照的 `current`,**0.1.6 起**该字段随「视图选中项」一起搬出了会话域,改看 `retainedBy.mainView > 0`(官方 `dsh-client-ui-session` 与本地 codegraph 的同一判据)——只认老字段会让菜单判成「当前没有打开的会话」而整组置灰;同一份取值也驱动「切会话把容器标签带过去」。
11
+ - **显式一键连接本机**:两个**非常驻**入口——一个目标都没有时是「还没有配置 Docker 目标」空态里的主按钮,已有别的目标时是**目标下拉的末一项** `+ 连接本机`(工具条上**不挂**:常驻时它既占位又招人点,纯噪音)——一次点击(或选中该项)就把宿主所在机器加成本机目标并选中(已有本机目标则直接复用,不重复创建;SSH / 自定义目标原样保留),agent 侧同一条操作是 `docker_connect_local`。它**不**改空 `targets` 的语义、**不**凭空造隐式默认目标、**不**自动装 Docker / 起 daemon / 切 docker context,也不需要任何能力授权(只加一条指向插件自身的本机目标)。
11
12
  - **多目标聚合取数**:总览页对全部 `targets[]` 并行请求,单个目标不可达只污染自己那一格;agent 侧同一口径由 `docker_ps target:"*"` / `docker_attention target:"*"` 暴露,跨目标不互相阻塞。
12
13
  - **「需关注」读权威字段**:不健康 / 反复重启 / OOM 被杀 / 非零退出 / 僵死;OOM 与真实退出码由一次 `docker inspect` 补齐——`docker ps` 摘要里的 137 分不出 OOM 与手动 kill,只按摘要筛必然误报。
13
14
  - **四条 SSE 长流共用一套基建**:日志 FOLLOW、`docker stats`、`docker events`、`docker pull` 走同一个 `openSseStream`(心跳 / 活跃流登记 / 断开清理),差异只在收尾语义——日志与拉取自然结束,统计与事件由前端主动断。多选聚合日志按 `--timestamps` 前缀还原跨容器真实时序,「暂停」只冻结渲染(流继续接收,恢复时一次性补齐)。
@@ -172,6 +173,13 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
172
173
  health_status / destroy / rename / update):`exec_*`、`archive-path`(`docker cp`)
173
174
  这类噪音在服务端就丢掉了——实测一台跑批机器 24 小时 47 条事件全是 exec,白名单
174
175
  命中 0,所以只被 exec 的机器上活动条是空的,这是刻意的。
176
+ 断开后**要手动重连**:这条流进了 `closed` 就是终止态(EventSource 只在连接层抖动
177
+ 时自愈;`docker events` 进程退出、服务端发 `end` 帧都会走 close),活动条上会出现
178
+ 一个 **重连** 按钮(只在 `closed` 时出现;`unsupported` 环境不给——重连必然再失败),
179
+ 工具条的 ⟳ 也一并重连。**AUTO REFRESH 轮询刻意不重连**:塞进轮询会让一条已终止的流
180
+ 被不断重开,既掩盖「它断了」,又白建 SSE。重连成功后补一次全量列表刷新(断线期间
181
+ 列表可能已经落后),daemon 全程没起、流从未连上过的情形同样补——否则活动条绿了而
182
+ 列表还停在错误态,看着像好了其实没好。
175
183
  - **聚合选择(临时多选聚合日志)**:工具条的 `聚合选择` 进入选择态——每张卡片
176
184
  左侧出现勾选框,点卡片本体变成**勾选 / 取消**(不再进详情;动作条暂时收起,
177
185
  免得一边多选一边误点启停删),工具条与列表之间出现操作条:「已选 N 个容器」+
@@ -347,6 +355,61 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
347
355
  `permission denied while trying to connect to the Docker daemon socket`
348
356
  之类的错误。
349
357
 
358
+ #### 连接本机(一键)
359
+
360
+ 不想手填目标时,UI 里有两个入口,都**不做常驻**:
361
+
362
+ | 何时 | 入口在哪 |
363
+ | --- | --- |
364
+ | 一个目标都没有 | 面板正文的空态里一个 **连接本机** 主按钮(那时整块是空的,一键就是最短路径) |
365
+ | 已经有别的目标 | **目标下拉的末一项** `+ 连接本机`——就在你找机器的地方,点开才出现 |
366
+
367
+ 两处互斥(不会同一个入口出现两次):没有目标时走空态那个大按钮;有目标时下拉里才有那一项。
368
+ **已经有本机目标时两处都不出现**(选择器里已经有那台本机了,再列一项等于重复)。
369
+
370
+ agent 侧同一件事是 `docker_connect_local` 工具,HTTP 侧是 `POST /connect-local`——
371
+ 所有入口共用宿主里同一份实现。
372
+
373
+ 为什么不做成常驻的工具条按钮:常驻一个「连接本机」既占掉目标选择器右边的位置,又要靠
374
+ 强调色招人点,而**已经有可用目标**时它是纯噪音(第一版就是常驻的,用户当场指出多余)。
375
+ 但只留空态那一个入口又会漏掉「手上有几台 SSH、想再加一台本机」这种常见情况——所以
376
+ 第二个入口放进下拉,只有在真的去找机器时才出现。
377
+
378
+ | 情况 | 行为 |
379
+ | --- | --- |
380
+ | 已有一条 `kind=local` 目标(无论叫什么名字) | **复用它**(`created:false`),只切过去;不新建、不重写、不改名 |
381
+ | 没有本机目标 | 用稳定名 `local` 追加到列表末尾;`local` 已被别的目标占用时依次试 `local-2`、`local-3`…(确定性、不冲突、不覆盖) |
382
+ | 已有 SSH / 自定义目标 | **原样保留**(含顺序与凭据);只在末尾追加本机那条 |
383
+ | 重复点 / 并发点 | 幂等:最终只有一条本机目标 |
384
+ | 设置存储不可用 / 保存失败 | 明确报错(503 / 500 + 原因),**不改内存配置**——不会出现「界面上加上了、刷新就没了」 |
385
+
386
+ 写盘成功之后**顺带做一次只读探测**(`docker version`),结果放进响应的 `message`:
387
+ daemon 可达时给出服务端版本;不可达时把成因说清楚——**docker CLI 缺失**(去装 Docker,
388
+ 或把「docker CLI」改成正确路径)/ **探测超时**(daemon 卡死,或 `docker context` 指向了
389
+ 连不上的远端;用 `docker context ls` 看当前指向)/ **daemon 未启动**(Linux
390
+ `systemctl start docker`,macOS / Windows 启动 Docker Desktop)/ **socket 无权访问**
391
+ (账号加入 `docker` 组),而不是只回一句「没有容器」。探测失败**不算这次操作失败**
392
+ (目标确实加上了,`reachable:false` 单独表示),下次 daemon 起来就能直接用。
393
+
394
+ 探测用的二进制**就是设置卡片里的「docker CLI」**(`dockerBin`,默认 `docker`)。
395
+ CLI 缺失时会**只读地**在 `PATH` 里找一下别的容器 CLI(`docker` → `podman` → `nerdctl`),
396
+ 找到就在那句话里点名(「这台机器上装了 podman:把『docker CLI』填成 podman 就能用」)——
397
+ **只提示,绝不替你改配置**:静默改写 `dockerBin` 会违背本插件「不替用户决定」的取向,
398
+ 而且 podman 与本插件输出格式的兼容性并未逐项验证(见「已知限制」)。比对候选时按
399
+ **工具名**(basename)而不是配置里的原字符串,所以 `dockerBin` 写成
400
+ `/usr/local/bin/docker` 时不会出现「把 docker 改成 docker」这种废话。
401
+
402
+ 超时是**独立一档**:`probe()` 不只报退出码(`code` 为 `null` 时那句「退出码 null」对用户
403
+ 零信息量),而是回 `timedOut:true` + 一句可执行的话。超时还会**真的按时返回**——
404
+ SIGKILL 只作用于直接子进程,`dockerBin` 指向包装脚本时孙进程仍握着管道,会把手上的
405
+ 15 秒上限拖成分钟级(真机实测 3 秒上限拖到 60 秒),所以到点先杀、再给 500ms 收敛期,
406
+ 仍不 `close` 就自行放掉管道收尾。
407
+
408
+ 刻意**不做**的事(保守边界):不自动安装 Docker、不启动 daemon、不切换 docker
409
+ context、不改任何环境变量;空 `targets`(= 用户没配)的语义不变,不产生隐式默认目标;
410
+ `clearTargets` 的防误清空逻辑一字未动。这条路径也**与能力开关无关**——它只写插件
411
+ 自己的配置,不涉及任何需要授权的目标侧写操作。
412
+
350
413
  ### 聚合日志(多选 / Compose 项目)
351
414
 
352
415
  多选容器或打开一个 Compose 项目,都能把多个容器的日志聚合成一条流(每个容器一条
@@ -436,7 +499,7 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
436
499
  | --- | --- | --- |
437
500
  | `enabled` | true | 关闭整个插件(**保存即热生效**:工具立即注销、公告撤下、除 `/config` 外的数据路由 403;`/config` 始终可读写——设置卡片就是重新启用的入口。与 tty 不同:tty 是重启生效) |
438
501
  | `announceToAgent` | true | 是否向 agent 注入能力公告(systemPrompt section `plugin:dsh-docker`) |
439
- | `dockerBin` | `docker` | docker CLI 可执行名或路径(podman 可填 `podman`);只允许字母、数字与 `_ . / \ : -` 及内部空格,且不能以 `-` 开头(**Windows 盘符与 `\` 必须放行**,否则任何绝对路径都填不进来) |
502
+ | `dockerBin` | `docker` | docker CLI 可执行名或路径(podman 可填 `podman`);只允许字母、数字与 `_ . / \ : -` 及内部空格,且不能以 `-` 开头(**Windows 盘符与 `\` 必须放行**,否则任何绝对路径都填不进来)。**「连接本机」的探测用的就是它**;填错(CLI 不存在)时那句话会点名 `PATH` 里找到的别的容器 CLI,但**不会**替你改这个值 |
440
503
  | `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,对应工具不注册) |
441
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-一条请求经过什么) |
442
505
  | `allowExec` | false | 允许一次性 `docker exec`(面板 exec 输入与 `docker_exec` 工具;关闭时 `/exec` 返回 403) |
@@ -495,6 +558,7 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
495
558
  | 工具 | 注册条件 | 参数 | 作用 / 典型用法 |
496
559
  | --- | --- | --- | --- |
497
560
  | `docker_targets` | 恒注册 | `probe?: boolean` | 列出目标(name / kind / label);`probe:true` 逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)。其他工具的 `target` 取自这里 |
561
+ | `docker_connect_local` | 恒注册 | —(无参数) | **连接本机**:确保存在一条 `kind=local` 目标并选中它(已存在则复用,不重复创建;SSH / 自定义目标原样保留),随后只读探测 daemon:返回 `{name, kind, created, saved, reachable, serverVersion?}`。没有任何目标时 `docker_ps` 等会报「尚未配置任何 Docker 目标」,用它一步补上。**不需要任何能力授权**(只写插件自己的配置 + 只读探测);不会安装 Docker / 启动 daemon / 切 context / 改环境变量。daemon 不可达时在结果文本里说清成因(CLI 缺失 / 探测超时 / daemon 未启动 / socket 无权;CLI 缺失时还会点名 PATH 里找到的别的容器 CLI,但不会替你改 dockerBin),不是只报空列表 |
498
562
  | `docker_ps` | 恒注册 | `target?`(**传 `*` = 全部目标**)、`all?: boolean` | 列容器(名称 / 状态 / 健康 / 镜像 / 端口 / compose 项目与服务 / 短 ID);默认只列运行中,`all:true` 含已停止。`target:'*'` 时按目标分组返回,**单个目标不可达不影响其他目标**(该组带 `error`)。端口是 IPv4/IPv6 双栈归并后的映射(同一次 `-p` 不再出现两遍,D130);`ports` 为空**不等于**没暴露端口——host 网络容器的端口即宿主机端口,这种情况会给 `net` 字段(D131)。排障第一步 |
499
563
  | `docker_attention` | 恒注册 | `target?`(支持 `*`)、`limit?: number` | **需关注汇总**:不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死;每条带 `reasons`、`exitCode`、`oomKilled`、`restartCount`。OOM 与真实退出码来自一次 `docker inspect`(ps 摘要里 137 无法区分手动 kill)。排障入口:不确定从哪台/哪个容器看起时先调它 |
500
564
  | `docker_inspect` | 恒注册 | `target?`、`id`(必填) | `docker inspect` 的权威详情:状态 / 健康检查 / 退出码 / 重启次数 / 端口 / 挂载 / 网络 / 启动命令 |
@@ -529,8 +593,8 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
529
593
  `referrer-policy: no-referrer`。
530
594
 
531
595
  **除 loopback 之外还有一道「同源证明」**:四条 SSE(`/logs/stream`、`/stats/stream`、`/events/stream`、
532
- `/images/pull/stream`)与八条变更子路由(`/action`、`/images/remove|prune`、`/networks/remove|prune`、
533
- `/volumes/remove|prune`、`/exec`)要求请求带 `Origin: <同源>` 或 `Sec-Fetch-Site: same-origin`,
596
+ `/images/pull/stream`)与九条写子路由(`/action`、`/images/remove|prune`、`/networks/remove|prune`、
597
+ `/volumes/remove|prune`、`/exec`、`/connect-local`)要求请求带 `Origin: <同源>` 或 `Sec-Fetch-Site: same-origin`,
534
598
  否则 403 `缺少同源证明`。这是为了挡住「恶意页面用 `<img src=.../images/pull/stream>` 触发一次真实拉取」
535
599
  这类跨站副作用;curl / 老 Safari / 部分 WebView 不带这两个头时会撞上它(浏览器正常使用不受影响)。
536
600
  只读路由不要求同源证明。
@@ -550,6 +614,7 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
550
614
  | `/config` | GET | — | `{ok:true, config}`:配置快照(targets 只给 `passwordSet` / `passphraseSet`,另附只读的 `ttyBooks` / `ttyAvailable` / `toolsRegistered`) |
551
615
  | `/config` | POST | 上表配置键的任意子集 | `{ok:true, config}`;未知键 400,非法 JSON 400 |
552
616
  | `/targets` | GET / POST | — | `{ok:true, targets:[{name, kind, label?\|error?}]}` |
617
+ | `/connect-local` | POST | `{}`(请求体被忽略) | **一键连接本机**:复用或新建 `kind=local` 目标并返回 `{ok:true, result:{name, kind, created, saved, reachable, serverVersion?, message}, config}`;宿主没有 settings 服务时 503,保存失败时 500(都带原因且不改内存配置)。要求同源证明(它是写路径),但**不需要** `allowMutations` / `allowExec` |
553
618
  | `/probe` | POST | `{target?}` | `{ok:true, probe:{ok, bin, serverVersion, error, target}}` |
554
619
  | `/containers` | POST | `{target?, all?}` | `{ok:true, containers: ContainerSummary[]}`;`target:'*'` 时返回 `{ok:true, groups:[{target,label,ok,error?,data?}]}`(跨目标并发聚合) |
555
620
  | `/attention` | POST | `{target?, limit?}` | 单目标 `{ok:true, items: AttentionItem[], total, truncated, degraded}`(`limit` 在过滤 + 严重度排序**之后**生效,默认 100、上限 500);`target:'*'` 时 `{ok:true, groups:[{target,label,ok,error?,data:{items,total,truncated,degraded}}]}` |
@@ -760,9 +825,11 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
760
825
  - **变更操作无独立审计日志**:只有 docker 自身的记录与宿主 `ctx.logger` 的
761
826
  常规输出。
762
827
 
763
- - **跨目标聚合的边界**:并发上限 4、单目标超时 45s;单个目标失败/超时只影响它自己那一格
764
- (组里带 `error`)。目标很多时总览的请求量随目标数线性增长(每目标 2 个请求),自动刷新
765
- 会放大这个量——目标多时建议关掉自动刷新。
828
+ - **跨目标聚合的边界**:并发上限 4;**单目标预算 90s(兜底)**,而每条命令自己的超时
829
+ (30s)才是被遵守的那个——到点时**底层的子进程 / SSH 通道会被真的取消**(不是只让这一格
830
+ 返回失败),否则超时那条命令会继续占着远端会话槽(D161)。单个目标失败/超时只影响它自己
831
+ 那一格(组里带 `error`)。目标很多时总览的请求量随目标数线性增长(每目标 2 个请求),
832
+ 自动刷新会放大这个量——目标多时建议关掉自动刷新。
766
833
 
767
834
  ## 工作原理
768
835
 
@@ -864,7 +931,7 @@ inspect 解析(状态 / 健康 / 退出码 / 挂载 / 网络 / 端口 / 缺字
864
931
  `resolveTarget` 的四种路径、`mergeTargetSecrets` 的凭证保留语义。
865
932
 
866
933
  `scripts/route-smoke.mjs` 用**假 cordis ctx + 假 docker CLI 脚本**跑端到端:
867
- 插件挂载(settings / 工具 / 路由 / 能力公告注册)、**26 条路由**的实际调用与返回
934
+ 插件挂载(settings / 工具 / 路由 / 能力公告注册)、**27 条路由**的实际调用与返回
868
935
  (含 `/logs/stream`、`/stats/stream`、`/events/stream`、`/images/pull/stream` 四条 SSE
869
936
  的事件序列与参数校验,事件流另断噪音被白名单丢掉)、`docker_events` 工具的快照
870
937
  输出与 `since` 字符集校验、`/images/inspect` 的详情 + 构建历史、`/config` 凭证脱敏、未知配置键
@@ -942,15 +1009,28 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
942
1009
  2. **SSH 目标**:tty 连接簿里已有条目时,用 `book` 引用它 → 容器列表 / 详情 /
943
1010
  日志正常;首次连接日志里出现「已记录 host key 指纹(TOFU)」,第二次不再
944
1011
  提示;手动改掉 `hostKeys` 里的指纹后重连,应**被拒绝**并给出重置指引。
945
- 3. **只读拦截**:两个开关都关时,`/action`、`/exec`、`/images/remove`、
946
- `/images/prune`、`/images/pull/stream` 全部 403;agent 侧恒注册 11 个只读工具
947
- (`docker_targets` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` /
1012
+ 3. **一键连接本机**(两处入口都要试):
1013
+ - **没有目标时**:把目标全部删空保存(或用一个全新 profile),打开面板 → 正文空态里
1014
+ 有一个「连接本机」主按钮,**下拉里不该重复列**这一项 → 点一下应当出现一条
1015
+ `kind=local` 的 `local` 并立刻选中、容器列表出来;
1016
+ - **已有别的目标时**:留着几台 SSH,打开目标下拉 → 末一项是 `+ 连接本机`,
1017
+ **工具条上不该有**任何常驻入口 → 选中该项,应当同样加上 `local` 并切过去,
1018
+ 且该项随即从下拉里消失(已经有本机目标了)。
1019
+ 两处都**不该**出现「同一个入口列了两次」;全程**不该**有任何 Docker 安装 / daemon 启动 /
1020
+ context 切换动作。宿主上没有 docker 时,应当看到一句点名的原因(CLI 缺失 / 探测超时 /
1021
+ daemon 未启动 / socket 无权访问),而不是一个空列表。
1022
+ - **只有 podman 的机器**(把 PATH 里的 docker 拿掉、只留 podman):那句话应当
1023
+ **点名 podman** 并说明「把『docker CLI』填成 podman 就能用」,同时**不该**替你
1024
+ 改 `dockerBin`(回头去设置卡片看,值仍是 `docker`)。
1025
+ 4. **只读拦截**:两个开关都关时,`/action`、`/exec`、`/images/remove`、
1026
+ `/images/prune`、`/images/pull/stream` 全部 403;agent 侧恒注册 12 个只读工具
1027
+ (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` /
948
1028
  `images` / `image_inspect` / `networks` / `volumes`,数量以工具表为准),
949
1029
  面板的启停删 / 镜像删除 / 清理 / 拉取按钮置灰。打开「允许变更操作」后这些
950
1030
  路由与工具立即出现(无需重启)。
951
- 4. **日志 / 统计 / 镜像**:`tail` 与 `timestamps` / `since` 生效;统计显示
1031
+ 5. **日志 / 统计 / 镜像**:`tail` 与 `timestamps` / `since` 生效;统计显示
952
1032
  CPU、内存、网络与块 IO;镜像列表含 dangling 条目标记。
953
- 5. **FOLLOW 实时日志流**:日志页打开 `FOLLOW` → 状态行先「正在连接」后
1033
+ 6. **FOLLOW 实时日志流**:日志页打开 `FOLLOW` → 状态行先「正在连接」后
954
1034
  「实时跟随中」,`docker logs -f` 的新行即时出现(`docker run --rm alpine sh
955
1035
  -c 'i=0; while :; do echo line-$i; i=$((i+1)); sleep 1; done'` 可观察);
956
1036
  高吞吐护栏:换成长行快速打印(如 `head -c 2000 /dev/zero | tr "\\0" x; echo` 循环)
@@ -961,61 +1041,61 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
961
1041
  (或热改插件配置)→ 状态行短暂「正在重连」后自愈,不弹错误横幅。SSH 目标
962
1042
  同样跑一遍,确认流跑着时同一主机的 `docker ps` 面板操作不受影响(连接复用),
963
1043
  且空闲回收(120s)不会掐断流。
964
- 6. **统计实时跟随**:统计页打开 `FOLLOW` →「正在连接统计流…」→「实时跟随中
1044
+ 7. **统计实时跟随**:统计页打开 `FOLLOW` →「正在连接统计流…」→「实时跟随中
965
1045
  (docker stats)」,CPU / 内存 sparkline 每秒长一点(跑 `docker run --rm
966
1046
  alpine sh -c 'while :; do :; done'` 观察 CPU 起来);关 FOLLOW 立即回快照并
967
1047
  恢复轮询。停掉被统计的容器 → 流收到 `end`(stats-exit)提示后自动回轮询。
968
1048
  注意**这条流不会自然结束**:切页 / 关面板必须断掉 `EventSource`(宿主侧应
969
1049
  看到 `docker stats` 被 SIGTERM)。
970
- 7. **镜像详情 / 删除 / 清理**:镜像页点某行「详情」→ 概览里的层数与
1050
+ 8. **镜像详情 / 删除 / 清理**:镜像页点某行「详情」→ 概览里的层数与
971
1051
  `docker image inspect` 一致、构建历史与 `docker history` 一致(Docker ≥ 26
972
1052
  走 `--format`,老版本走纯文本表格兜底);dangling 行用镜像 ID 查。点「删除」→
973
1053
  二次确认后执行 `docker image rm`;删除被容器引用的镜像应失败并给出提示。
974
1054
  「清理 dangling」只删无标签镜像,输出末尾带 `Total reclaimed space`。
975
- 8. **拉取进度流**:镜像页工具条的拉取图标(悬停显示「拉取镜像」)→ 输入本地没有的小镜像(如 `alpine:3.20`)
1055
+ 9. **拉取进度流**:镜像页工具条的拉取图标(悬停显示「拉取镜像」)→ 输入本地没有的小镜像(如 `alpine:3.20`)
976
1056
  → 逐层状态行实时出现且按层原地更新;完成后提示「拉取完成」并自动刷新列表。
977
1057
  拉取中点「停止」或离开视图 → `docker pull` 被 SIGTERM 结束,不残留进程。
978
1058
  未开启「允许变更操作」时该按钮置灰、直接访问 `/images/pull/stream` 返回 403。
979
- 9. **Compose 项目视图**:用一份 compose 起两三个服务(`docker compose up -d`)→
1059
+ 10. **Compose 项目视图**:用一份 compose 起两三个服务(`docker compose up -d`)→
980
1060
  工具条切到「Compose」→ 服务归到一个项目卡片下,运行数 / 服务数 / 状态正确;
981
1061
  点进项目看服务表,切「聚合日志」→ 各服务日志按 `[service]` 前缀混流出现
982
1062
  (`docker compose logs -f` 的等价物),过滤框可按服务名与内容过滤;关「自动
983
1063
  滚动」后新日志继续进缓冲但视图不跳。无 compose 标签的容器归入
984
1064
  「(非 compose 容器)」。
985
- 10. **`allowMutations` 打开后**:stop / start / restart 成功;对运行中容器
1065
+ 11. **`allowMutations` 打开后**:stop / start / restart 成功;对运行中容器
986
1066
  remove 报错并附「先停止再删除」提示,先 stop 再 remove 成功。
987
- 11. **`allowExec` 打开后**:`ls -la /app` 之类命令返回 stdout 与退出码;把命令
1067
+ 12. **`allowExec` 打开后**:`ls -la /app` 之类命令返回 stdout 与退出码;把命令
988
1068
  换成 `sleep 60`(`timeoutSec` 调小)应被中断并报超时;`command` 超过 8000
989
1069
  字符被拒绝。
990
- 12. **exec 抽屉与正文共存**(tty ≥ 0.15):卡片「终端」开抽屉后切到日志 / 统计 /
1070
+ 13. **exec 抽屉与正文共存**(tty ≥ 0.15):卡片「终端」开抽屉后切到日志 / 统计 /
991
1071
  另一台容器的详情,抽屉与终端会话都必须活着;**折叠**(箭头或双击顶边)把抽屉
992
1072
  压成一条标题栏、会话继续跑(展开即回原样);**拖拽顶边**能调高度且不超过面板
993
1073
  的 75%;此时点 backdrop 空白处或面板 ✕ 应弹「结束容器终端会话」确认,取消后
994
1074
  会话仍在,确认才结束(tty 侧收到 kill)。
995
1075
  离线回归:`node packages/tty/scripts/preview.mjs docker-exec-logs`
996
1076
  (无头 Chrome 跑真实客户端:抽屉 + 日志页 + 折叠展开,断言会话未被结束)。
997
- 13. **从连接栏进容器面板(dock 模式,需 tty ≥ 0.16)**:tty 面板里开一个 SSH 标签 →
1077
+ 14. **从连接栏进容器面板(dock 模式,需 tty ≥ 0.16)**:tty 面板里开一个 SSH 标签 →
998
1078
  连接栏「容器」→ 容器面板应挂在**终端右侧**(不是全屏弹窗),终端仍可输入;
999
1079
  拖左边缘可调宽(上限面板宽 72%)、标题栏箭头折叠成窄条(终端拿回全部宽度、
1000
1080
  容器面板不卸载)、✕ 收起面板且 SSH 标签不受影响;此时点卡片「终端」应在同一
1001
1081
  终端面板**新开一个 `<容器> · exec` 标签**,而不是再嵌一个终端抽屉。
1002
1082
  离线回归:`node packages/tty/scripts/preview.mjs docker-dock`
1003
1083
  (断言 docked / 无 backdrop / 折叠后终端变宽 / 容器面板存活)。
1004
- 14. **多选聚合日志**:容器列表点 `聚合选择` → 卡片左侧出现勾选框、卡片动作条收起;
1084
+ 15. **多选聚合日志**:容器列表点 `聚合选择` → 卡片左侧出现勾选框、卡片动作条收起;
1005
1085
  勾 2~3 个容器 → 操作条显示「已选 3 个容器」,点 `聚合日志` → 聚合视图标题为
1006
1086
  「聚合日志 · 3 个容器」,三条流按 `[service]` / 容器名前缀混流出现;只勾 1 个时
1007
1087
  按钮置灰并提示「至少选择 2 个容器」,勾到 9 个时置灰并提示最多 8 个;列表里
1008
1088
  停掉其中一个容器 → 该条流走原有 `end` 语义结束,其余流不受影响。
1009
- 15. **Esc 退出选择态**:选择态下按 Esc → 回到普通列表、勾选清空、动作条消失;
1089
+ 16. **Esc 退出选择态**:选择态下按 Esc → 回到普通列表、勾选清空、动作条消失;
1010
1090
  再点一次 `聚合选择`(此时标签是 `退出选择`)效果相同;切目标 / 切分段 / 关面板
1011
1091
  也都会把选择态和勾选一起清掉。
1012
- 16. **事件活动条**:容器列表头部出现「活动」条,状态点先黄后绿、文案「实时接收中
1092
+ 17. **事件活动条**:容器列表头部出现「活动」条,状态点先黄后绿、文案「实时接收中
1013
1093
  (docker events)」;执行 `docker restart <容器>` / `docker stop`+`start` → 1 秒内
1014
1094
  列表状态跟着变,活动条出现 `stop` / `start`(异常退出显示 `die(137)` 这种带码
1015
1095
  形式);点标题可折叠(事件流不中断,展开后仍是累积的最新 8 条);
1016
1096
  `docker exec` 连续几次**不产生任何事件**(`exec_*` 已被服务端白名单丢掉);
1017
1097
  断网 / 重启 `dsh web` 后恢复时状态点短暂黄色并自动补一次全量列表刷新。
1018
- 17. **网络 / 卷**:工具条分段出现「网络」「卷」。网络列表应含 `bridge` / `host` / `none`
1098
+ 18. **网络 / 卷**:工具条分段出现「网络」「卷」。网络列表应含 `bridge` / `host` / `none`
1019
1099
  与 compose 创建的项目网络;点某条进详情 → 概览里子网 / 网关与 `docker network
1020
1100
  inspect` 一致、`internal` 网络带徽标,「接入的容器」页签列出容器名与 IPv4。
1021
1101
  卷列表的挂载点与 `docker volume ls` 一致(超长路径省略、悬停看全量)。
@@ -1024,7 +1104,7 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
1024
1104
  卷清理会弹带「数据会一起删除」的确认框。**注意**:卷清理在 docker < 23 上会连
1025
1105
  命名卷一起删,先在测试目标上确认为妙。
1026
1106
 
1027
- 18. **多目标总览**:配两个以上目标(本机 + 一台 SSH)→ 目标选择器旁出现 `总览` pill,
1107
+ 19. **多目标总览**:配两个以上目标(本机 + 一台 SSH)→ 目标选择器旁出现 `总览` pill,
1028
1108
  点进去一屏出现两张计数卡(各带「本机」/「SSH」徽标)与异常区;卡片数字与该目标
1029
1109
  容器列表的口径一致。把其中一个目标的 SSH 地址改错(或停掉远端 docker)再刷新 →
1030
1110
  只有那张卡描红、顶部出现「1 个目标不可达」,另一张卡照常显示结果,**页面不会**
@@ -1032,7 +1112,7 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
1032
1112
  **该目标**的容器列表而不是总览;点计数卡 → 该目标的容器列表。全部正常时异常区显示
1033
1113
  「一切正常」;还有目标没答完时显示「读取中…」。打开「自动刷新」→ 按
1034
1114
  `pollIntervalSec` 轮询全部目标(关掉即停)。
1035
- 19. **切目标不串台(列表写入闸)**:让 目标1(不可达的 SSH)先失败出「操作失败」横幅,
1115
+ 20. **切目标不串台(列表写入闸)**:让 目标1(不可达的 SSH)先失败出「操作失败」横幅,
1036
1116
  随即切到正常的目标2 → 横幅应**随切换立即消失**、列表是 目标2 的容器;再等 20s
1037
1117
  让 目标1 的超时响应**在其之后**才回来 → 横幅不得重新出现、列表也不得被换成
1038
1118
  目标1 的容器。从总览点 目标1 那张红色计数卡进去,同样不该看到上一个目标的失败。