@hyzyn/dsh-docker 0.9.6 → 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
@@ -537,7 +537,7 @@ return 400).
537
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 |
538
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) |
539
539
  | `allowExec` | false | allows a one-shot `docker exec` (the panel's exec input and the `docker_exec` tool; while off, `/exec` returns 403) |
540
- | — (capability grant) | not granted | `allowMutations` / `allowExec` have **two out-of-band channels**: ① **launch environment variables** (`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`, values `1` / `true` / `yes` / `on`) — resolved from the host's **launch snapshot**, inherited `process` layer only, so writing project `.env` or `~/.dsh/env.yml` does **not** count as a grant; ② **grant in place** (no restart): click the switch in the settings card and run the command it shows in a terminal on the host — effective within seconds, on its own row (one row per capability — the grants are per capability, so side-by-side revoke buttons would not say which one belongs to which), next to a `Granted · YYYY-MM-DD HH:mm:ss` line and a `Revoke host grant` button (a launch-environment grant has no timestamp and cannot be revoked from the UI). In-place grants are **persistent**: they live in `<DSH home>/dsh-kit/capability-grants.json` (0600; the confirmation dir is `<DSH home>/dsh-kit/grant-confirm/`, 0700 — kept inside the kit's own subdirectory, because mechanism names dropped into the shared home root can collide with the harness or another plugin), are read at the next boot, and then **take effect with no further confirmation** — so every boot logs one `elevation: load capability=… via=file grantedAt=…` line per loaded grant. Over HTTP they can always be **turned off** (the emergency brake must not depend on a restart), but setting `true` without a grant is rejected with 400 and both routes spelled out. A `true` in the config is **not** a grant (it lives in the same store HTTP writes to, so the source is indistinguishable). **Upgrade note**: switches opened through the UI before this change become off — set the variable and restart, or grant in place. **Why**: neither the loopback fence nor the same-origin proof stops cross-site pages or in-page scripts (they can just set `Sec-Fetch-Site: same-origin`), and the docker socket is root on the target host; details (including whom this does *not* stop) in [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
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-一条请求经过什么) |
541
541
  | `execTimeoutSec` | 30 | default exec timeout in seconds (1–120) |
542
542
  | `pollIntervalSec` | 5 | stats refresh interval for the panel, in seconds (1–60) |
543
543
  | `logTailDefault` | 200 | default log tail line count (1–5000) |
@@ -615,6 +615,18 @@ and reconnect". The record list can be deleted / reset in the settings card (del
615
615
  required, and the error message lists the available target names.
616
616
  - Changing either switch **re-registers the tools immediately**: after turning off `allowMutations` / `allowExec`
617
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).
618
630
  - Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs` →
619
631
  `docker_inspect` → `docker_stats`; for image problems `docker_images` →
620
632
  `docker_image_inspect`.
@@ -775,6 +787,11 @@ read-only first:
775
787
  `id` through the `assertRef` allowlist and clamps parameters the same way. Unlike a snapshot, a long stream has no
776
788
  `maxOutputKb` cap (following would lose its point if truncated), and memory protection is shouldered by the
777
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".
778
795
 
779
796
  ## Known limitations
780
797
 
@@ -869,8 +886,24 @@ read-only first:
869
886
  withdrawn, and data routes other than `/config` return 403 (long streams in progress — logs / stats / pulling —
870
887
  are wrapped up immediately too). The route objects themselves are not unloaded; they are blocked by the 403, and
871
888
  `/config` stays readable and writable — the settings card is the way back in, with no restart of `dsh web` needed.
872
- - **Mutating operations have no separate audit log**: only docker's own records and the host `ctx.logger`'s
873
- ordinary output.
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.
874
907
 
875
908
  - **The boundaries of cross-target aggregation**: concurrency cap 4; a **90s per-target budget (fallback)**, while
876
909
  each command's own timeout (30s) is the one actually honoured — when a budget fires, the underlying child process /
package/README.md CHANGED
@@ -466,6 +466,14 @@ context、不改任何环境变量;空 `targets`(= 用户没配)的语义
466
466
  所以必须有个名字。**一个能力一行**:开关后面跟这个能力自己的授权状态与**授权时刻**(`已授权 · 2026-09-26 22:31:08`),
467
467
  撤销按钮右对齐——两个能力的授权是分开的,并排放会看不出哪个撤销管哪个能力。
468
468
 
469
+ **两个能力的确认也能一次粘贴搞定**:面板开着时再点另一个开关不会顶掉前一块面板(两个能力的
470
+ 授权本来就互不影响),两块面板**纵向堆叠**,顶部多出一个「复制全部(N 条)」——它把仍 pending 的
471
+ 命令按**发起顺序**用**换行符**连接后一次复制,用户去宿主终端**粘贴一次**即可:`bash` / `zsh` /
472
+ PowerShell / cmd 粘贴多行都逐行执行,所以刻意不用 `&&`(PowerShell 5.1 不认)或 `;`(cmd 不认)
473
+ 连接。某条过期后,合并按钮只复制仍 pending 的那些;全部解锁(或全部收起)后它自己消失。两个
474
+ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后全部开关自动解锁」、**不承诺同一时刻**
475
+ ——倒计时仍是逐面板显示,每块面板的复制 / 重新生成 / 收起也照旧只管自己那一个能力。
476
+
469
477
  为什么显示时刻:带外授权是**持久**的——宿主重启后它**直接生效、不再有任何一次确认**。也就是说
470
478
  「上个月授权的能力今天一开机就开着」这件事不会有任何提示,除非界面与日志说出来。所以宿主启动时会
471
479
  逐条打一行 `[dsh-docker] elevation: load capability=… via=file grantedAt=…`(不含 nonce 与路径),
@@ -501,7 +509,7 @@ context、不改任何环境变量;空 `targets`(= 用户没配)的语义
501
509
  | `announceToAgent` | true | 是否向 agent 注入能力公告(systemPrompt section `plugin:dsh-docker`) |
502
510
  | `dockerBin` | `docker` | docker CLI 可执行名或路径(podman 可填 `podman`);只允许字母、数字与 `_ . / \ : -` 及内部空格,且不能以 `-` 开头(**Windows 盘符与 `\` 必须放行**,否则任何绝对路径都填不进来)。**「连接本机」的探测用的就是它**;填错(CLI 不存在)时那句话会点名 `PATH` 里找到的别的容器 CLI,但**不会**替你改这个值 |
503
511
  | `allowMutations` | false | 允许**变更操作**:容器 start / stop / restart / remove、镜像删除 / dangling 清理 / 拉取(面板按钮与 `docker_action`、`docker_image_remove`、`docker_image_prune`、`docker_image_pull` 工具;关闭时 `/action`、`/images/remove`、`/images/prune`、`/images/pull/stream` 返回 403,对应工具不注册) |
504
- | —(能力授权) | 未授权 | `allowMutations` / `allowExec` 有**两条提权通道**:① **启动环境变量**(`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`,值为 `1` / `true` / `yes` / `on`)——判定源是宿主的**启动环境快照**,只认继承来的 `process` 层:写项目 `.env` 或 `~/.dsh/env.yml` **不算**授权;② **就地提权**(免重启):在设置卡片点开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内生效。HTTP 侧永远可以**关掉**它们(紧急刹车不能依赖重启),但给 `true` 而无授权会被 400 拒绝并说清两条路。配置里的 `true` **不算授权**(它与 HTTP 写进去的值存在同一个存储里,分不出来源)。**升级影响**:升级前靠界面打开的开关会变成关——设环境变量重启,或在卡片里就地确认。**为什么**:回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 `Sec-Fetch-Site: same-origin`),而 docker socket 等价目标主机 root;细节(含拦不住谁)见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
512
+ | —(能力授权) | 未授权 | `allowMutations` / `allowExec` 有**两条提权通道**:① **启动环境变量**(`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`,值为 `1` / `true` / `yes` / `on`)——判定源是宿主的**启动环境快照**,只认继承来的 `process` 层:写项目 `.env` 或 `~/.dsh/env.yml` **不算**授权;② **就地提权**(免重启):在设置卡片点开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内生效;两个能力各点一次后,面板组顶部会给一个「复制全部(N 条)」(仍 pending 的命令按发起顺序换行连接),终端粘贴一次即可全部解锁(各自探测周期内先后生效,不承诺同一时刻)。HTTP 侧永远可以**关掉**它们(紧急刹车不能依赖重启),但给 `true` 而无授权会被 400 拒绝并说清两条路。配置里的 `true` **不算授权**(它与 HTTP 写进去的值存在同一个存储里,分不出来源)。**升级影响**:升级前靠界面打开的开关会变成关——设环境变量重启,或在卡片里就地确认。**为什么**:回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 `Sec-Fetch-Site: same-origin`),而 docker socket 等价目标主机 root;细节(含拦不住谁)见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
505
513
  | `allowExec` | false | 允许一次性 `docker exec`(面板 exec 输入与 `docker_exec` 工具;关闭时 `/exec` 返回 403) |
506
514
  | `execTimeoutSec` | 30 | exec 默认超时秒数(1~120) |
507
515
  | `pollIntervalSec` | 5 | 面板统计刷新间隔秒数(1~60) |
@@ -579,6 +587,15 @@ context、不改任何环境变量;空 `targets`(= 用户没配)的语义
579
587
  列出可用目标名。
580
588
  - 两个开关变化会**立即重注册**工具:关掉 `allowMutations` / `allowExec` 后,
581
589
  对应工具(含三个镜像变更工具)从 agent 侧消失,无需重启。
590
+ - **已注册的调用还受会话权限档位闸(tier gate)约束**:按类分——`read` 类(上表「恒注册」
591
+ 的全部)全档放行;`docker_action` / `docker_image_remove` / `docker_image_prune` /
592
+ `docker_image_pull` 归 `write` 类,`docker_exec` 归 `exec` 类,这两类在受限档(仅可查看 /
593
+ 工作区内修改)下**每次调用弹授权询问**(人允许这一次才执行;无人值守即审批策略 `never`
594
+ 时确定性拒绝),完全权限档零询问,Auto review 档不叠加(宿主的模型预审已在管)。宿主未组合
595
+ `sandboxPolicy` / `approval` 服务时不闸(行为与引入前一致),启动日志打一行
596
+ `tier-gate: services absent (…), per-call gate disabled`。它与上面两个静态开关是**双层**:
597
+ 静态开关管注册(宿主级、持久授权),档位闸管已注册调用的放行(逐调用、随档位即时变)。
598
+ 机制见 [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md)。
582
599
  - 推荐排障顺序:`docker_targets` → `docker_ps` → `docker_logs` →
583
600
  `docker_inspect` → `docker_stats`;镜像排查 `docker_images` →
584
601
  `docker_image_inspect`。
@@ -726,6 +743,11 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
726
743
  `id` 过 `assertRef` 白名单、参数经同样的夹紧。与快照不同,长流没有
727
744
  `maxOutputKb` 上限(跟随被截断就失去意义),内存防护由客户端的 5000 行
728
745
  环形缓冲与 2000 行着色上限承担。
746
+ 9. **变更操作有逐条使用审计**。kit elevation 的五行审计回答「授权了」,这里回答
747
+ 「用了」:每一次**过了能力闸**的变更 / exec 都落一行 `[dsh-docker] capability-use: …`
748
+ 结构化审计行(出口是宿主 stdout),面板路由与 agent 工具两条入口都覆盖。行格式与
749
+ 不记什么(403 / tier-gate ask)、以及「exec 命令会随行出现」这条代价,见「已知限制」
750
+ 的使用审计条。
729
751
 
730
752
  ## 已知限制
731
753
 
@@ -822,8 +844,22 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
822
844
  数据路由一律 403(进行中的长流——日志 / 统计 / 拉取——也会被立即收尾)。
823
845
  路由对象本身不卸载、靠 403 拦截;`/config` 始终可读写,设置卡片就是重新
824
846
  启用的入口,无需重启 `dsh web`。
825
- - **变更操作无独立审计日志**:只有 docker 自身的记录与宿主 `ctx.logger` 的
826
- 常规输出。
847
+ - **使用审计走宿主 stdout,exec 命令会随行出现**:每一次**过了能力闸**的变更 / exec
848
+ (面板的变更 / exec 路由与 agent 的 `docker_action` / `docker_image_remove` /
849
+ `docker_image_prune` / `docker_image_pull` / `docker_exec`;403 与参数校验 400 不记,
850
+ 「使用 = 过了闸」)落一行 `[dsh-docker] capability-use: capability=allowMutations|allowExec
851
+ source=http|tool action=container.remove|… target=… ref=… ok=true|false durationMs=…`,
852
+ key=value 风格与 kit elevation 的授权行一致,`capability` 与授权行直接对得上号;pull
853
+ 进度流拆 start / end 两行(宿主中途挂掉时至少 start 还在)。**出口是 `console.log`**
854
+ (docker D162:真机实测 `ctx.logger` 的行在 0.2.x 宿主上既不进 stdout 也不落盘,而审计行的
855
+ 意义全在「出问题时翻得到」;tier-gate 的日志出口同此取向)——跑 `dsh web` 的那个终端里
856
+ 就能看到、可 grep。kit elevation 的授权行同走宿主
857
+ stdout(**kit D14**,两类行同通道,可对照检索)。**代价**:exec 的命令会记进行尾 `detail`(连同退出码,
858
+ 如 `detail=code=0 cmd=ls -la /app`),整段截断 200 字符、控制字符转义成 `\n` 等字面量——
859
+ 行是给人看的,就得接受它出现在终端可见的日志流里,与 elevation「nonce 绝不进日志」是
860
+ 同一类取舍的正面。docker 报错也算一次使用(`ok=false` + `detail=` 截断后的错误文案);
861
+ tier-gate 的 ask / deny 不记(没执行不算使用,放行与否由宿主 approval 日志覆盖)。
862
+ 没有查询界面、没有独立审计文件——要检索就翻宿主 stdout。
827
863
 
828
864
  - **跨目标聚合的边界**:并发上限 4;**单目标预算 90s(兜底)**,而每条命令自己的超时
829
865
  (30s)才是被遵守的那个——到点时**底层的子进程 / SSH 通道会被真的取消**(不是只让这一格
@@ -2231,7 +2231,24 @@
2231
2231
  /*
2232
2232
  * 就地授权面板:一段「带外确认」的说明 + 一条要复制到宿主终端的命令。
2233
2233
  * 左侧色条把它与普通设置项区分开——它不是配置项,是一个**动作区**。
2234
+ *
2235
+ * 面板组(`dk_elevPanels`):两个能力可以同时 pending(面板开着时再点另一个开关),
2236
+ * 于是多块面板**纵向堆叠**、顶部出一次「复制全部」。最小限度的间距规则,不做视觉重设计。
2234
2237
  */
2238
+ .dk_elevPanels {
2239
+ display: grid;
2240
+ grid-template-columns: minmax(0, 1fr);
2241
+ min-width: 0;
2242
+ gap: var(--dk-gap-md);
2243
+ }
2244
+ /* 合并复制行:主按钮 + 一句说明并排,窄栏下允许换行(说明文案中英都较长) */
2245
+ .dk_elevMerge {
2246
+ display: flex;
2247
+ flex-wrap: wrap;
2248
+ align-items: center;
2249
+ gap: var(--dk-gap-sm);
2250
+ min-width: 0;
2251
+ }
2235
2252
  .dk_elevPanel {
2236
2253
  display: grid;
2237
2254
  /*
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @hyzyn/dsh-docker — 就地提权「逐能力 pending 记录」的纯逻辑。
3
+ *
4
+ * 为什么单独成文件(本仓客户端硬规矩 ②,也是 ROADMAP 记过的教训):离线冒烟的 React 桩把
5
+ * `useState` 冻在初值上、**不执行函数组件体**,状态留在卡片闭包里就等于没有测试入口——
6
+ * 「两个 pending 时取哪几条命令、按什么顺序、怎么连接」这条判定必须能直接驱动。
7
+ *
8
+ * 背景(2026-09-26 设置卡片截图实测):单槽 state 下点第二个开关会把第一个能力的面板**替换**掉,
9
+ * 前一条命令从界面消失(宿主侧 challenge 仍在 pending),用户于是要在宿主终端**粘贴两次**。
10
+ * 改成逐能力记录后,两块面板同时在场,合并复制把 N 条命令一次交给用户。
11
+ */
12
+
13
+ /**
14
+ * 能力名 → 该能力的 pending 记录。**数组**(不是 Map):面板按发起顺序纵向堆叠,
15
+ * 顺序本身就是界面语义(先点的排在上面),而对象键序在删改后不再可靠。
16
+ *
17
+ * 记录形状(与卡片里的 state 逐字对应):
18
+ * - `{ capability, command, expiresAt }` —— 宿主已回 begin 的 pending
19
+ * - `{ capability, error }` —— begin 失败 / 抛错
20
+ * - `{ capability }` —— begin 在途(还没拿到命令)
21
+ */
22
+
23
+ /**
24
+ * 增改一条记录,**不动**别的 pending(这是本改造的核心:单槽 → 逐能力)。
25
+ *
26
+ * 幂等:同一能力再次 begin(「重新生成」按钮)**原地替换**那条记录、保留它原来的位置——
27
+ * 重排会让面板在用户眼皮底下跳走。新能力追加到末尾,于是「发起顺序」就是数组顺序。
28
+ */
29
+ export function upsertElevationRecord(records, capability, changes) {
30
+ const list = Array.isArray(records) ? records : []
31
+ const index = list.findIndex((item) => item !== null && item !== undefined && item.capability === capability)
32
+ if (index === -1) return [...list, { capability, ...changes }]
33
+ const next = list.slice()
34
+ next[index] = { ...list[index], ...changes }
35
+ return next
36
+ }
37
+
38
+ /** 关掉**一个**能力的面板(面板上的 ✕):其余 pending 原样留着。 */
39
+ export function removeElevationRecord(records, capability) {
40
+ const list = Array.isArray(records) ? records : []
41
+ return list.filter((item) => item === null || item === undefined || item.capability !== capability)
42
+ }
43
+
44
+ /**
45
+ * 收掉「已授权」的留痕:组里一个 pending 都不剩时,整组清空(含留痕)。
46
+ *
47
+ * 为什么必须有:留痕只服务「还有别的能力没解锁」这一个场景。若让它单独活下来,
48
+ * 下一次点开关会出现「一条留痕 + 一条新 pending」的假两组——合并按钮就会为一个**单个**
49
+ * pending 冒出来(正是验收里「单个 pending → 无合并按钮」要挡掉的那种界面)。
50
+ */
51
+ export function pruneElevationRecords(records) {
52
+ const list = Array.isArray(records) ? records : []
53
+ const hasPending = list.some((item) => item !== null && item !== undefined && item.granted !== true)
54
+ return hasPending ? list : []
55
+ }
56
+
57
+ /**
58
+ * 某个能力授权到了:标成「已授权」并清掉命令(面板据此消失),**记录本身先留着**。
59
+ *
60
+ * 为什么留痕而不是立刻删掉(本改造最容易做错的一处):合并按钮的存续跟着「这一组里点过几个
61
+ * 能力」,不是跟着「当前还有几条命令」——
62
+ * - 两个都点过、其中一个先解锁 → 按钮留着把**剩下那条**交给用户(此时把他赶回单条面板里找
63
+ * 命令,等于把刚刚那次「一次粘贴」拆回去);
64
+ * - 全都解锁 → 组里没有可复制命令,留痕由 `pruneElevationRecords` 收掉、按钮自然消失;
65
+ * - 只点过一个(单个 pending)→ 组里只有一条记录,压根不出按钮(那条命令就在自己的面板里)。
66
+ *
67
+ * **不存在**这个能力时一个字段都不动(返回原数组):否则「点了一个宿主其实早已授权的开关」
68
+ * 会凭空多出一条空记录,跟着影响合并按钮的判定。
69
+ */
70
+ export function markElevationGranted(records, capability) {
71
+ const list = Array.isArray(records) ? records : []
72
+ let touched = false
73
+ const next = list.map((item) => {
74
+ if (item === null || item === undefined || item.capability !== capability) return item
75
+ touched = true
76
+ return {
77
+ capability,
78
+ command: undefined,
79
+ expiresAt: undefined,
80
+ error: undefined,
81
+ granted: true,
82
+ }
83
+ })
84
+ return touched ? pruneElevationRecords(next) : list
85
+ }
86
+
87
+ /**
88
+ * 这条记录**曾经拿到过命令**吗(现在是否还 pending 不问)。
89
+ *
90
+ * 合并按钮的「参与计数」用它:一个能力点过开关、宿主也回过命令,它就参与过这次「一次粘贴」;
91
+ * 只有没拿到过命令的(begin 报错 / 还在途)不参与——它们没有可粘贴的东西。
92
+ */
93
+ export function hasElevationCommand(record) {
94
+ return record !== null && record !== undefined
95
+ && (record.granted === true || (typeof record.command === 'string' && record.command !== ''))
96
+ }
97
+
98
+ /**
99
+ * 从逐能力记录里取出「合并复制」要用的命令,**按发起顺序**(= 数组顺序)。
100
+ *
101
+ * 只收真的拿到命令、且**尚未过期**的那些:
102
+ * - 在途(没有 command)与失败(有 error)本来就没有可执行的命令;
103
+ * - 过期的那条再复制过去只会让宿主侧 `probe` 落一个「过期后出现的文件」——用户以为解锁了、
104
+ * 界面却在倒计时归零后不动(最坏的一种困惑),所以过期的从合并里剔掉。
105
+ *
106
+ * 参数 `now` 缺省取当前时刻,便于用例钉住边界(`expiresAt` 恰好等于 now 算过期)。
107
+ */
108
+ export function mergeElevationCommands(records, now) {
109
+ const at = now === undefined ? Date.now() : now
110
+ const list = Array.isArray(records) ? records : []
111
+ const commands = []
112
+ for (const item of list) {
113
+ if (item === null || item === undefined) continue
114
+ if (item.granted === true) continue
115
+ if (typeof item.command !== 'string' || item.command === '') continue
116
+ if (Number.isFinite(item.expiresAt) && Number(item.expiresAt) <= at) continue
117
+ commands.push(item.command)
118
+ }
119
+ return commands
120
+ }
121
+
122
+ /**
123
+ * 合并复制的**唯一**连接方式:换行符。
124
+ *
125
+ * 为什么不是 `&&` / `;`(刻意写死在这里,别在调用点各写一遍):
126
+ * - PowerShell 5.1 不认 `&&`(PS 7 才加),cmd 不认 `;`——用它们连接等于挑平台;
127
+ * - 换行是**四条 shell 都逐行执行**的形态:bash / zsh 逐行跑、PowerShell 与 cmd 粘贴多行
128
+ * 同样逐行跑。命令本身是 `touch '…'` / `powershell …`,两条之间没有依赖,不需要条件连接。
129
+ */
130
+ export function joinElevationCommands(commands) {
131
+ const list = Array.isArray(commands) ? commands : []
132
+ return list.filter((command) => typeof command === 'string' && command !== '').join('\n')
133
+ }
134
+
135
+ /**
136
+ * 「复制全部」那一个动作:取命令 + 连接,合成最终要写进剪贴板的字符串。
137
+ *
138
+ * 把两步合成一个纯函数是为了让**验收门槛逐字节可钉**:合并按钮复制的内容必须恰好等于
139
+ * 各条 pending 命令按发起顺序用 `\n` 连接的字节串(多一个换行、少一条、顺序反了都算错)。
140
+ */
141
+ export function buildMergedElevationText(records, now) {
142
+ return joinElevationCommands(mergeElevationCommands(records, now))
143
+ }
144
+
145
+ /**
146
+ * 「复制全部」那个主按钮要不要出。
147
+ *
148
+ * 两个条件缺一不可:
149
+ * 1. **组里至少两条记录拿到过命令**(`hasElevationCommand`)——不是「点了几个开关」:
150
+ * 点了开关但 begin 报错、或 begin 还在途的能力**没有**可粘贴的东西,让它们参与计数会出
151
+ * 一个只剩一条命令的「复制全部(1 条)」,那正是本仓最忌讳的「按钮说了但它做的事没意义」;
152
+ * 2. **当前至少还有一条可复制的命令**(两个都解锁 / 都过期之后,按钮必须自己消失——留着它
153
+ * 等于承诺一个不可能发生的解锁)。
154
+ *
155
+ * 为什么已经解锁的那条**仍然计数**(条件 1 看的是「拿到过命令」而不是「现在还 pending」):
156
+ * 两个能力各点一次之后,其中一个先解锁是常态(两个探测周期独立)。此时剩下那条仍要让用户
157
+ * **一次粘贴**拿到手——按钮消失会把他赶回单条面板里再找一遍,等于把刚做的动作拆回去。
158
+ */
159
+ export function shouldOfferMerge(records, now) {
160
+ const list = Array.isArray(records) ? records : []
161
+ const participants = list.filter(hasElevationCommand).length
162
+ return participants >= 2 && buildMergedElevationText(list, now) !== ''
163
+ }