@hyzyn/dsh-docker 0.9.5 → 0.10.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
@@ -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,10 +534,10 @@ 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
- | — (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-一条请求经过什么) |
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). **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-一条请求经过什么) |
467
541
  | `execTimeoutSec` | 30 | default exec timeout in seconds (1–120) |
468
542
  | `pollIntervalSec` | 5 | stats refresh interval for the panel, in seconds (1–60) |
469
543
  | `logTailDefault` | 200 | default log tail line count (1–5000) |
@@ -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 |
@@ -540,6 +615,18 @@ and reconnect". The record list can be deleted / reset in the settings card (del
540
615
  required, and the error message lists the available target names.
541
616
  - Changing either switch **re-registers the tools immediately**: after turning off `allowMutations` / `allowExec`
542
617
  the corresponding tools (including the three image mutation tools) disappear from the agent side, with no restart.
618
+ - **Registered calls are also subject to the session permission tier gate (tier gate)**: by class — every
619
+ “always registered” tool in the table above is `read` (allowed on every tier); `docker_action` /
620
+ `docker_image_remove` / `docker_image_prune` / `docker_image_pull` are `write`, `docker_exec` is `exec`,
621
+ and these two classes trigger an **approval prompt on every call** under a confined tier (Read Only /
622
+ Workspace Write — the call runs only if the user allows this one; deterministically denied when unattended,
623
+ i.e. approval policy `never`). Full access never prompts, and under Auto review the plugin does not add its
624
+ own prompt (the host’s model pre-review already covers every call). When the host has not composed the
625
+ `sandboxPolicy` / `approval` services the gate is off (behaviour identical to before), and the startup log
626
+ says so: `tier-gate: services absent (…), per-call gate disabled`. This is a **separate layer** from the two
627
+ static switches above: the switches govern registration (host-level, persistent grant), the tier gate governs
628
+ each registered call (per call, follows the tier immediately). Mechanism:
629
+ [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md).
543
630
  - Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs` →
544
631
  `docker_inspect` → `docker_stats`; for image problems `docker_images` →
545
632
  `docker_image_inspect`.
@@ -556,8 +643,8 @@ loopback, `localhost`, or a hostname / `/etc/hosts` alias that **resolves to thi
556
643
  `referrer-policy: no-referrer`.
557
644
 
558
645
  **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
646
+ `/stats/stream`, `/events/stream`, `/images/pull/stream`) and nine write sub-routes (`/action`,
647
+ `/images/remove|prune`, `/networks/remove|prune`, `/volumes/remove|prune`, `/exec`, `/connect-local`) require either
561
648
  `Origin: <same origin>` or `Sec-Fetch-Site: same-origin`, otherwise 403 `缺少同源证明` (missing same-origin
562
649
  proof). This blocks cross-site side effects such as a malicious page using
563
650
  `<img src=.../images/pull/stream>` to trigger a real pull; curl, old Safari and some WebViews do not send those
@@ -579,6 +666,7 @@ returned 403 on Desktop — the symptom was the logs / stats / events / pull str
579
666
  | `/config` | GET | — | `{ok:true, config}`: the config snapshot (targets expose only `passwordSet` / `passphraseSet`, plus the read-only `ttyBooks` / `ttyAvailable` / `toolsRegistered`) |
580
667
  | `/config` | POST | any subset of the config keys above | `{ok:true, config}`; an unknown key is 400 and invalid JSON is 400 |
581
668
  | `/targets` | GET / POST | — | `{ok:true, targets:[{name, kind, label?\|error?}]}` |
669
+ | `/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
670
  | `/probe` | POST | `{target?}` | `{ok:true, probe:{ok, bin, serverVersion, error, target}}` |
583
671
  | `/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
672
  | `/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}}]}` |
@@ -699,6 +787,11 @@ read-only first:
699
787
  `id` through the `assertRef` allowlist and clamps parameters the same way. Unlike a snapshot, a long stream has no
700
788
  `maxOutputKb` cap (following would lose its point if truncated), and memory protection is shouldered by the
701
789
  client's 5000-line ring buffer and 2000-line colouring cap.
790
+ 9. **Mutating operations get a per-use audit trail**. The five audit lines from kit elevation answer "what was
791
+ authorized"; this answers "what it was used for": every mutation / exec that **passes the capability gate** lands
792
+ one structured `[dsh-docker] capability-use: …` audit line (outlet: the host's stdout), covering both the panel
793
+ routes and the agent tools. The line format, what is *not* logged (403 / tier-gate asks), and the cost that exec
794
+ commands appear alongside the lines are described in the usage-audit entry under "Known limitations".
702
795
 
703
796
  ## Known limitations
704
797
 
@@ -793,13 +886,31 @@ read-only first:
793
886
  withdrawn, and data routes other than `/config` return 403 (long streams in progress — logs / stats / pulling —
794
887
  are wrapped up immediately too). The route objects themselves are not unloaded; they are blocked by the 403, and
795
888
  `/config` stays readable and writable — the settings card is the way back in, with no restart of `dsh web` needed.
796
- - **Mutating operations have no separate audit log**: only docker's own records and the host `ctx.logger`'s
797
- ordinary output.
798
-
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.
889
+ - **The usage audit goes to the host's stdout, and exec commands appear in it**: every mutation / exec that
890
+ **passes the capability gate** (the panel's mutation / exec routes and the agent tools `docker_action` /
891
+ `docker_image_remove` / `docker_image_prune` / `docker_image_pull` / `docker_exec`; 403 rejections and parameter
892
+ validation 400s are not logged — "a use = something that passed the gate") lands one
893
+ `[dsh-docker] capability-use: capability=allowMutations|allowExec source=http|tool action=container.remove|…
894
+ target=… ref=… ok=true|false durationMs=…` line in the same key=value style as kit elevation's authorization
895
+ lines, with `capability` matching those lines directly; the pull progress stream logs a start / end pair (if the
896
+ host dies mid-stream, at least the start line remains). **The outlet is `console.log`** (docker D162: on a real
897
+ host, `ctx.logger` lines were measured to reach neither the host's stdout nor any file, and an audit line whose
898
+ whole point is "findable when something goes wrong" is worthless in an invisible channel; tier-gate's log outlet
899
+ makes the same call) — the terminal running `dsh web` shows them, grep works. Kit elevation's authorization lines share the same stdout outlet
900
+ (kit D14 — both line families on one channel, searchable side by side). **The cost**: the exec command is recorded in the
901
+ trailing `detail` field (together with the exit code, e.g. `detail=code=0 cmd=ls -la /app`), truncated to 200
902
+ characters overall with control characters escaped as `\n` literals — a line meant for humans to read has to
903
+ accept appearing in the terminal-visible log stream, the same trade-off in the positive direction as elevation's
904
+ "nonce never enters the log". A docker error also counts as a use (`ok=false` plus `detail=` with the truncated
905
+ error text); tier-gate asks / denies are not logged (nothing was executed, and the host's approval log already
906
+ covers them). There is no query UI and no separate audit file — the way to search it is the host's stdout.
907
+
908
+ - **The boundaries of cross-target aggregation**: concurrency cap 4; a **90s per-target budget (fallback)**, while
909
+ each command's own timeout (30s) is the one actually honoured — when a budget fires, the underlying child process /
910
+ SSH channel is **really cancelled** (not merely reported as a failed cell), otherwise the timed-out command keeps
911
+ holding a remote session slot (D161). A single target failing or timing out affects only its own cell (the group
912
+ carries `error`). With many targets the Overview's request volume grows linearly with the target count (2 requests
913
+ per target), and auto refresh multiplies that volume — with many targets it is best to turn auto refresh off.
803
914
 
804
915
  ## How it works
805
916
 
@@ -889,7 +1000,7 @@ injection)**, `assertBin`, `formatBytes`, `shJoin` escaping, `DockerApi`'s argv
889
1000
 
890
1001
  `scripts/route-smoke.mjs` runs end to end with **a fake cordis ctx + a fake docker CLI script**: plugin
891
1002
  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`,
1003
+ **27 routes** (including the event sequences and parameter validation of the four SSE streams `/logs/stream`,
893
1004
  `/stats/stream`, `/events/stream`, `/images/pull/stream`, with the event stream additionally asserting that noise is
894
1005
  dropped by the allowlist), the `docker_events` tool's snapshot output and its `since` character-set validation,
895
1006
  `/images/inspect`'s details + build history, `/config` credential masking, 400 for unknown config keys, 403 for
@@ -977,15 +1088,30 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
977
1088
  details / logs work; the first connection logs "host key fingerprint recorded (TOFU)" and the second does not
978
1089
  prompt again; after manually changing the fingerprint in `hostKeys` and reconnecting, the connection should
979
1090
  **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` /
1091
+ 3. **Connect local (one click)** — exercise **both** entries:
1092
+ - **With no targets**: clear every target and save (or use a fresh profile), open the panel — the
1093
+ empty state in the body shows a "Connect local" primary button, and the dropdown should **not**
1094
+ list it again; one click should produce a `kind=local` target named `local`, select it
1095
+ immediately, and load the container list;
1096
+ - **With other targets**: keep a few SSH hosts and open the target dropdown — the last item is
1097
+ `+ Connect local`, and the toolbar must carry **no** permanent entry; picking it should likewise
1098
+ add `local` and switch to it, after which the item disappears from the dropdown (a local target
1099
+ now exists).
1100
+ Neither place may show "the same entry listed twice"; nothing should install Docker, start a daemon
1101
+ or switch contexts along the way. On a host without docker you should see a named cause (CLI
1102
+ missing / probe timed out / daemon not running / no socket permission) rather than an empty list.
1103
+ - **On a machine that only has podman** (drop docker from `PATH`, leave podman): that sentence
1104
+ should **name podman** and say "put podman in 'docker CLI' and it works", while **not**
1105
+ rewriting `dockerBin` for you (go back to the settings card — the value is still `docker`).
1106
+ 4. **Read-only interception**: with both switches off, `/action`, `/exec`, `/images/remove`,
1107
+ `/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly 12 read-only tools are
1108
+ registered (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` / `images` /
983
1109
  `image_inspect` / `networks` / `volumes` — see the tool table for the authoritative list), and
984
1110
  the panel's start / stop / remove, image removal, pruning and pull buttons are greyed out. After turning on
985
1111
  "allow mutations" these routes and tools appear immediately (no restart needed).
986
- 4. **Logs / stats / images**: `tail` and `timestamps` / `since` take effect; stats show
1112
+ 5. **Logs / stats / images**: `tail` and `timestamps` / `since` take effect; stats show
987
1113
  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
1114
+ 6. **FOLLOW live log stream**: turning on `FOLLOW` on the log page → the status line first says
989
1115
  "connecting" and then "following live", and new lines from `docker logs -f` appear immediately (`docker run --rm alpine sh
990
1116
  -c 'i=0; while :; do echo line-$i; i=$((i+1)); sleep 1; done'` makes this observable);
991
1117
  with FOLLOW on, `AUTO REFRESH` is greyed out and polling stops; scrolling up brings up "back to bottom",
@@ -996,26 +1122,26 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
996
1122
  itself, with no error banner. Run the same with an SSH target and confirm that panel operations such as
997
1123
  `docker ps` on the same host are unaffected while the stream runs (connection reuse), and that idle reclamation
998
1124
  (120s) does not cut the stream.
999
- 6. **Stats live following**: turning on `FOLLOW` on the stats page → "connecting to the stats stream…" → "following
1125
+ 7. **Stats live following**: turning on `FOLLOW` on the stats page → "connecting to the stats stream…" → "following
1000
1126
  live (docker stats)", and the CPU / memory sparklines grow a little every second (run `docker run --rm
1001
1127
  alpine sh -c 'while :; do :; done'` to watch CPU rise); turning FOLLOW off immediately returns to snapshots and
1002
1128
  resumes polling. Stop the containers being measured → the stream receives `end` (stats-exit), hints, and then
1003
1129
  returns to polling automatically.
1004
1130
  Note that **this stream does not end naturally**: switching pages / closing the panel must close the `EventSource`
1005
1131
  (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
1132
+ 8. **Image details / removal / pruning**: click "details" on a row of the images page → the layer count in the
1007
1133
  overview matches `docker image inspect` and the build history matches `docker history` (Docker ≥ 26
1008
1134
  goes through `--format`, older versions fall back to the plain-text table); look up a dangling row by image ID.
1009
1135
  Click "remove" → after a second confirmation it runs `docker image rm`; removing an image that a container
1010
1136
  references should fail with a hint.
1011
1137
  "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
1138
+ 9. **Pull progress stream**: the pull icon in the images toolbar (hovering shows "pull image") → enter a small image
1013
1139
  you do not have locally (such as `alpine:3.20`)
1014
1140
  → per-layer status lines appear live and are updated in place by layer; when it finishes it hints "pull complete"
1015
1141
  and refreshes the list automatically.
1016
1142
  Clicking "stop" mid-pull or leaving the view → `docker pull` is terminated by SIGTERM with no leftover process.
1017
1143
  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`) →
1144
+ 10. **Compose project view**: start two or three services with a compose file (`docker compose up -d`) →
1019
1145
  switch the toolbar to "Compose" → the services are grouped under one project card with the correct
1020
1146
  running / service counts and states;
1021
1147
  click into the project to see the service table, switch to "merged logs" → the services' logs appear mixed by the
@@ -1023,12 +1149,12 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1023
1149
  content; after turning "auto
1024
1150
  scroll" off new logs keep entering the buffer without the view jumping. Containers without a compose label are
1025
1151
  grouped under "other containers (non-compose)".
1026
- 10. **After turning on `allowMutations`**: stop / start / restart succeed; removing a running container
1152
+ 11. **After turning on `allowMutations`**: stop / start / restart succeed; removing a running container
1027
1153
  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
1154
+ 12. **After turning on `allowExec`**: a command such as `ls -la /app` returns stdout and the exit code; changing the
1029
1155
  command to `sleep 60` (with `timeoutSec` lowered) should be interrupted and report a timeout; a `command` longer
1030
1156
  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
1157
+ 13. **The exec drawer coexists with the body** (tty ≥ 0.15): after opening the drawer from a card's "Terminal", switch
1032
1158
  to logs / stats / another container's details, and both the drawer and the terminal session must stay alive;
1033
1159
  **collapsing** (the arrow or double-clicking the top edge) squashes the drawer into one title bar and the session
1034
1160
  keeps running (expanding restores it); **dragging the top edge** changes the height without exceeding 75% of the
@@ -1037,7 +1163,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1037
1163
  Offline regression: `node packages/tty/scripts/preview.mjs docker-exec-logs`
1038
1164
  (headless Chrome runs the real client: drawer + log page + collapse and expand, asserting that the session was
1039
1165
  not ended).
1040
- 13. **Entering the container panel from the connection bar (dock mode, needs tty ≥ 0.16)**: open an SSH tab in the
1166
+ 14. **Entering the container panel from the connection bar (dock mode, needs tty ≥ 0.16)**: open an SSH tab in the
1041
1167
  tty panel →
1042
1168
  connection bar "Containers" → the container panel should dock **to the right of the terminal** (not a full-screen
1043
1169
  modal) while the terminal stays typable;
@@ -1047,7 +1173,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1047
1173
  `<container> · exec` tab in the same terminal panel** rather than nesting another terminal drawer.
1048
1174
  Offline regression: `node packages/tty/scripts/preview.mjs docker-dock`
1049
1175
  (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
1176
+ 15. **Multi-select merged logs**: click `Select for merging` in the container list → checkboxes appear on the left of
1051
1177
  the cards and the card action bars collapse;
1052
1178
  check 2–3 containers → the action bar shows "3 containers selected", and clicking `merged logs` → the merged
1053
1179
  view's title is
@@ -1055,11 +1181,11 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1055
1181
  checked the button is greyed out and hints "select at least 2 containers", and at 9 checked it is greyed out and
1056
1182
  hints at a maximum of 8; stop one of the containers from the list → that stream ends with the usual `end`
1057
1183
  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
1184
+ 16. **Esc leaves selection mode**: pressing Esc in selection mode → back to the normal list, checks cleared and the
1059
1185
  action bar gone;
1060
1186
  clicking `Select for merging` again (its label is now `Exit selection`) has the same effect; switching targets /
1061
1187
  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
1188
+ 17. **The Activity strip**: the "Activity" strip appears at the head of the container list, its status dot goes from
1063
1189
  yellow to green and the copy reads "receiving live
1064
1190
  (docker events)"; running `docker restart <container>` / `docker stop`+`start` → the
1065
1191
  list state follows within 1 second and the Activity strip shows `stop` / `start` (abnormal exits show the coded
@@ -1069,7 +1195,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1069
1195
  server-side allowlist);
1070
1196
  after the network drops / `dsh web` restarts, recovery briefly shows a yellow status dot and automatically does
1071
1197
  one full list refresh.
1072
- 17. **Networks / volumes**: the toolbar segments show "Networks" and "Volumes". The network list should contain
1198
+ 18. **Networks / volumes**: the toolbar segments show "Networks" and "Volumes". The network list should contain
1073
1199
  `bridge` / `host` / `none`
1074
1200
  plus project networks created by compose; click one to enter details → the subnet / gateway in the overview match
1075
1201
  `docker network
@@ -1082,7 +1208,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1082
1208
  volume pruning raises a confirmation saying "the data will be deleted as well". **Note**: on docker < 23 volume
1083
1209
  pruning also deletes named volumes, so it is best to confirm on a test target first.
1084
1210
 
1085
- 18. **Multi-target Overview**: configure two or more targets (local + one SSH) → the `Overview` pill appears next to
1211
+ 19. **Multi-target Overview**: configure two or more targets (local + one SSH) → the `Overview` pill appears next to
1086
1212
  the target picker;
1087
1213
  clicking it shows two counter cards on one screen (each badged "local" / "SSH") plus the attention area, with the
1088
1214
  card numbers matching that target's
@@ -1096,7 +1222,7 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
1096
1222
  "all good"; while targets are still answering it shows "reading…". Turning on "auto refresh" → polls all targets
1097
1223
  at
1098
1224
  `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
1225
+ 20. **Switching targets does not cross-talk (the list write gate)**: let Target1 (an unreachable SSH host) fail first
1100
1226
  and raise an "operation failed" banner,
1101
1227
  then immediately switch to the healthy Target2 → the banner should **disappear with the switch at once** and the
1102
1228
  list should be Target2's containers; then wait 20s