@hyzyn/dsh-docker 0.13.7 → 0.15.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.
@@ -16,7 +16,7 @@
16
16
  * 用法:pnpm --filter @hyzyn/dsh-docker build && node scripts/route-smoke.mjs
17
17
  */
18
18
  import { strict as assert } from 'node:assert'
19
- import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
19
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
20
20
  import { tmpdir } from 'node:os'
21
21
  import { join } from 'node:path'
22
22
 
@@ -27,6 +27,13 @@ import { join } from 'node:path'
27
27
  */
28
28
  process.env.DSH_DOCKER_ALLOW_MUTATIONS = '1'
29
29
  process.env.DSH_DOCKER_ALLOW_EXEC = '1'
30
+ /*
31
+ * 诊断历史的落盘目录(批三):**必须**在 import 插件之前指到临时目录——`/diagnose` 现在会真的
32
+ * 写盘,不隔离就会落到用户的 `~/.dsh/dsh-docker/`。冒烟脚本污染真实配置是本仓明令禁止的,
33
+ * 所以这里不是「可选隔离」:变量在 import 之前设,收尾连同假 CLI 的目录一起删。
34
+ */
35
+ const historyDir = mkdtempSync(join(tmpdir(), 'dsh-docker-history-smoke-'))
36
+ process.env.DSH_DOCKER_HISTORY_DIR = historyDir
30
37
 
31
38
  const host = await import('../lib/index.js')
32
39
 
@@ -69,8 +76,25 @@ const INSPECT_JSON = JSON.stringify([{
69
76
  Image: 'sha256:deadbeef',
70
77
  RestartCount: 1,
71
78
  State: { Status: 'running', ExitCode: 0, Pid: 123, Health: { Status: 'healthy', Log: [] } },
72
- Config: { Image: 'nginx:1.27', Cmd: ['nginx'], Labels: {} },
73
- HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
79
+ // Entrypoint / Env / Hostname 同时服务于 /run-command:hostname 等于容器 ID 前 12 位
80
+ // = docker 的默认值,等价命令里**不该**出现 --hostname(e2e 顺带钉住这条判据)
81
+ Config: {
82
+ Image: 'nginx:1.27',
83
+ Entrypoint: ['docker-entrypoint.sh'],
84
+ Cmd: ['nginx'],
85
+ Env: ['PATH=/usr/bin', 'DB_PASSWORD=secret-value'],
86
+ Labels: {},
87
+ User: '',
88
+ WorkingDir: '/app',
89
+ Hostname: '9f2c1d4e5a6b',
90
+ },
91
+ HostConfig: {
92
+ RestartPolicy: { Name: 'unless-stopped' },
93
+ PortBindings: { '80/tcp': [{ HostIp: '0.0.0.0', HostPort: '8080' }] },
94
+ NetworkMode: 'shop_default',
95
+ Memory: 0,
96
+ NanoCpus: 0,
97
+ },
74
98
  Mounts: [],
75
99
  NetworkSettings: { Ports: { '80/tcp': [{ HostIp: '0.0.0.0', HostPort: '8080' }] }, Networks: {} },
76
100
  }])
@@ -86,6 +110,33 @@ const INSPECT_BAD_JSON = JSON.stringify([{
86
110
  Mounts: [],
87
111
  NetworkSettings: { Ports: {}, Networks: {} },
88
112
  }])
113
+ /** `docker system df --format '{{json .}}'`:四行 NDJSON(真机形状,计数是字符串)。 */
114
+ const DISK_SUMMARY_TEXT = [
115
+ '{"Active":"10","Reclaimable":"1.134GB (56%)","Size":"2.011GB","TotalCount":"14","Type":"Images"}',
116
+ '{"Active":"1","Reclaimable":"0B (0%)","Size":"162B","TotalCount":"1","Type":"Containers"}',
117
+ '{"Active":"1","Reclaimable":"0B (0%)","Size":"197B","TotalCount":"2","Type":"Local Volumes"}',
118
+ '{"Active":"0","Reclaimable":"0B","Size":"0B","TotalCount":"0","Type":"Build Cache"}',
119
+ ].join('\n')
120
+
121
+ /** `docker system df -v --format '{{json .}}'`:一个 JSON 对象(Images / Volumes / BuildCache)。 */
122
+ const DISK_DETAIL_JSON = JSON.stringify({
123
+ Images: [
124
+ { Containers: '1', ID: 'sha256:aaaa1111bbbb2222', Repository: 'nginx', Tag: '1.27', Size: '142MB', SharedSize: '0B', UniqueSize: '142MB', VirtualSize: '142MB' },
125
+ { Containers: '0', ID: 'sha256:cccc3333dddd4444', Repository: 'alpine', Tag: '3.19', Size: '7MB', SharedSize: '7MB', UniqueSize: '0B', VirtualSize: '7MB' },
126
+ ],
127
+ Volumes: [
128
+ { Name: 'pgdata', Driver: 'local', Size: '197B', Links: '1' },
129
+ { Name: 'empty-vol', Driver: 'local', Size: '0B', Links: '0' },
130
+ ],
131
+ BuildCache: [{ ID: 'cache1', Type: 'regular', Size: '0B', InUse: 'false' }],
132
+ })
133
+
134
+ /** `docker top` 的假输出:表头是 ps 的默认写法(%CPU / %MEM),CMD 含空格。 */
135
+ const TOP_TEXT = `PID PPID USER STAT ELAPSED %CPU %MEM CMD
136
+ 3149 3125 1000 Ss 03:24:48 0.0 0.0 /sbin/docker-init -- docker-entrypoint.sh node dist/main.js
137
+ 3177 3149 1000 Sl 03:24:48 0.4 1.2 node dist/main.js
138
+ `
139
+
89
140
  const STATS_LINE = JSON.stringify({ ID: '9f2c1d4e5a6b', Name: 'shop-web-1', CPUPerc: '0.50%', MemUsage: '128MiB / 7.66GiB', MemPerc: '1.60%', NetIO: '1kB / 2kB', BlockIO: '0B / 0B', PIDs: '5' })
90
141
  const IMAGES_LINE = JSON.stringify({ ID: 'sha256:aaaa1111bbbb2222', Repository: 'nginx', Tag: '1.27', Size: '142MB', CreatedSince: '2 weeks ago' })
91
142
  const IMAGE_INSPECT_JSON = JSON.stringify([{
@@ -128,7 +179,8 @@ cmd="$1"
128
179
  case "$cmd" in
129
180
  version) echo "27.3.1" ;;
130
181
  ps) printf '%s\\n' '${PS_LINE}'; printf '%s\\n' '${PS_BAD_LINE}' ;;
131
- inspect) case "$*" in *bad0c0ffee*) printf '%s' '${INSPECT_BAD_JSON}' ;; *) printf '%s' '${INSPECT_JSON}' ;; esac ;;
182
+ inspect) case "$*" in *--format*) printf '%s' '/shop-web-1' ;; *bad0c0ffee*) printf '%s' '${INSPECT_BAD_JSON}' ;; *) printf '%s' '${INSPECT_JSON}' ;; esac ;;
183
+ top) printf '%s' '${TOP_TEXT}' ;;
132
184
  logs) echo "stdout line 1"; echo "stderr line 1" >&2 ;;
133
185
  stats) printf '%s\\n' '${STATS_LINE}' ;;
134
186
  images) printf '%s\\n' '${IMAGES_LINE}' ;;
@@ -143,6 +195,12 @@ case "$cmd" in
143
195
  history) printf '%s\\n' '${IMAGE_HISTORY_LINE}' ;;
144
196
  pull) echo "Pulling from library/nginx"; echo "Status: Downloaded newer image for nginx:1.27" ;;
145
197
  events) printf '%s\\n' '${EVENT_START_LINE}'; printf '%s\\n' '${EVENT_NOISE_LINE}'; printf '%s\\n' '${EVENT_DIE_LINE}' ;;
198
+ system)
199
+ case "$*" in
200
+ *-v*) printf '%s' '${DISK_DETAIL_JSON}' ;;
201
+ *) printf '%s' '${DISK_SUMMARY_TEXT}' ;;
202
+ esac
203
+ ;;
146
204
  network)
147
205
  case "$2" in
148
206
  ls) printf '%s\\n' '${NETWORK_LS_LINE}' ;;
@@ -394,9 +452,9 @@ function toolNames() {
394
452
  * 1. 挂载面
395
453
  * ------------------------------------------------------------------ */
396
454
 
397
- await test('挂载:注册 13 个只读 agent 工具', () => {
455
+ await test('挂载:注册 17 个只读 agent 工具', () => {
398
456
  const names = toolNames().sort()
399
- assert.deepEqual(names, ['docker_attention', 'docker_connect_local', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_logs_grep', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
457
+ assert.deepEqual(names, ['docker_attention', 'docker_connect_local', 'docker_diagnose', 'docker_disk_usage', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_logs_grep', 'docker_networks', 'docker_ps', 'docker_run_command', 'docker_stats', 'docker_targets', 'docker_top', 'docker_volumes'])
400
458
  })
401
459
 
402
460
  await test('挂载:注册能力公告 section', () => {
@@ -435,7 +493,7 @@ await test('GET /config:凭证脱敏 + 只读默认 + 复用 tty 连接簿名'
435
493
  const direct = config.targets.find((item) => item.name === '直连')
436
494
  assert.equal(direct.passwordSet, true)
437
495
  assert.equal(direct.passphraseSet, false)
438
- assert.deepEqual(config.toolsRegistered.sort(), ['docker_attention', 'docker_connect_local', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_logs_grep', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
496
+ assert.deepEqual(config.toolsRegistered.sort(), ['docker_attention', 'docker_connect_local', 'docker_diagnose', 'docker_disk_usage', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_logs_grep', 'docker_networks', 'docker_ps', 'docker_run_command', 'docker_stats', 'docker_targets', 'docker_top', 'docker_volumes'])
439
497
  })
440
498
 
441
499
  await test('POST /config:未知键被拒绝', async () => {
@@ -497,6 +555,148 @@ await test('POST /logs:合并 stdout 与 stderr', async () => {
497
555
  assert.match(res.body.logs.text, /stderr line 1/)
498
556
  })
499
557
 
558
+ await test('POST /top:容器内进程快照(docker top + 名字单独取一次)', async () => {
559
+ const res = await call('POST', '/top', { target: '本机', id: 'shop-web-1' })
560
+ assert.equal(res.status, 200)
561
+ const top = res.body.top
562
+ assert.equal(top.id, 'shop-web-1')
563
+ // 名字来自 `docker inspect --format '{{.Name}}'`(docker top 的输出里没有名字),前导斜杠已 strip
564
+ assert.equal(top.name, 'shop-web-1')
565
+ assert.equal(top.truncated, false)
566
+ assert.equal(top.rows.length, 2)
567
+ // 八列逐字:字段全是 string(docker 打印什么就是什么,不做单位换算)
568
+ assert.deepEqual(top.rows[0], {
569
+ pid: '3149',
570
+ ppid: '3125',
571
+ user: '1000',
572
+ stat: 'Ss',
573
+ elapsed: '03:24:48',
574
+ cpu: '0.0',
575
+ mem: '0.0',
576
+ command: '/sbin/docker-init -- docker-entrypoint.sh node dist/main.js',
577
+ })
578
+ for (const value of Object.values(top.rows[0])) assert.equal(typeof value, 'string')
579
+ })
580
+
581
+ await test('POST /top:缺 id 400 / 非法 id 400(白名单在路由层先跑,不进 docker)', async () => {
582
+ const missing = await call('POST', '/top', { target: '本机' })
583
+ assert.equal(missing.status, 400)
584
+ assert.match(missing.body.error, /id 必填/)
585
+ const badId = await call('POST', '/top', { target: '本机', id: 'shop-web-1; rm -rf /' })
586
+ assert.equal(badId.status, 400)
587
+ assert.match(badId.body.error, /container 含非法字符/)
588
+ })
589
+
590
+ await test('POST /diagnose:首次诊断落一条历史(recorded true),样本里没有日志原文', async () => {
591
+ const res = await call('POST', '/diagnose', { target: '本机', id: 'shop-web-1', logTail: 5, since: '30m' })
592
+ assert.equal(res.status, 200)
593
+ const history = res.body.diagnosis.history
594
+ assert.equal(history.samples.length, 1)
595
+ assert.equal(history.recorded, true)
596
+ // 样本 <2 → 趋势只能说「还不够」,不许编造结论
597
+ assert.equal(history.trend.verdict.code, 'insufficient')
598
+ assert.equal(history.trend.count, 1)
599
+ // 历史**只记结论与数字**:日志原文 / finding 的 detail 绝不能落盘(可能带凭证)
600
+ assert.equal(JSON.stringify(history).includes('stdout line 1'), false)
601
+ assert.equal(Object.hasOwn(history.samples[0], 'logTail'), false)
602
+
603
+ // 真的写到了隔离目录(正例:环境变量确实生效,否则这里读不到文件)
604
+ const file = join(historyDir, 'diagnosis-history.jsonl')
605
+ const text = readFileSync(file, 'utf8')
606
+ assert.match(text, /"container":"shop-web-1"/)
607
+ assert.equal(text.includes('stdout line 1'), false)
608
+ })
609
+
610
+ await test('POST /diagnose:30 秒内重复诊断不追加(去重);history:0 不回样本但仍记录', async () => {
611
+ const again = await call('POST', '/diagnose', { target: '本机', id: 'shop-web-1', logTail: 5 })
612
+ assert.equal(again.status, 200)
613
+ // 同签名 + 30s 内 → 不追加
614
+ assert.equal(again.body.diagnosis.history.recorded, false)
615
+ assert.equal(again.body.diagnosis.history.samples.length, 1)
616
+
617
+ const none = await call('POST', '/diagnose', { target: '本机', id: 'shop-web-1', logTail: 5, history: 0 })
618
+ assert.equal(none.status, 200)
619
+ assert.deepEqual(none.body.diagnosis.history.samples, [])
620
+ assert.equal(none.body.diagnosis.history.trend.verdict.code, 'insufficient')
621
+ // 去重命中,所以「这次也没追加」
622
+ assert.equal(none.body.diagnosis.history.recorded, false)
623
+
624
+ // 越界的 history 夹紧(不报错):100 → 最多 50,负数 → 0
625
+ const big = await call('POST', '/diagnose', { target: '本机', id: 'shop-web-1', logTail: 5, history: 100 })
626
+ assert.equal(big.status, 200)
627
+ assert.equal(big.body.diagnosis.history.samples.length <= 50, true)
628
+ const negative = await call('POST', '/diagnose', { target: '本机', id: 'shop-web-1', logTail: 5, history: -3 })
629
+ assert.equal(negative.status, 200)
630
+ assert.deepEqual(negative.body.diagnosis.history.samples, [])
631
+ })
632
+
633
+ await test('POST /diagnose:四路采集 → 契约形状(运行中且无异常 → running-ok)', async () => {
634
+ const res = await call('POST', '/diagnose', { target: '本机', id: 'shop-web-1', logTail: 5, since: '30m' })
635
+ assert.equal(res.status, 200)
636
+ const diagnosis = res.body.diagnosis
637
+ assert.equal(diagnosis.id, '9f2c1d4e5a6b')
638
+ assert.equal(diagnosis.name, 'shop-web-1')
639
+ assert.deepEqual(diagnosis.verdict, { severity: 'ok', code: 'running-ok', summary: '容器在运行,未发现明确问题' })
640
+ assert.deepEqual(diagnosis.findings, [])
641
+ // evidence:可缺字段一律给 null(不是省略、也不是 0)
642
+ assert.equal(diagnosis.evidence.state, 'running')
643
+ // status 是 ps 风格的 STATUS(不是 State.Status 的裸值):假 CLI 的 inspect 没有 StartedAt → 只有 'Up'
644
+ assert.equal(diagnosis.evidence.status, 'Up (healthy)')
645
+ assert.equal(diagnosis.evidence.health, 'healthy')
646
+ // 假 CLI 的 logs 是 stdout 一行 + stderr 一行;logs() 拼接时两段之间补了一个换行 → 3 行
647
+ assert.equal(diagnosis.evidence.logLines, 3)
648
+ assert.equal(diagnosis.evidence.errorLines, 0)
649
+ assert.equal(diagnosis.evidence.memoryLimitBytes, null)
650
+ assert.equal(diagnosis.evidence.cpuQuota, null)
651
+ assert.equal(diagnosis.evidence.cpuPercent, 0.5)
652
+ assert.equal(diagnosis.evidence.memPercent, 1.6)
653
+ // 三行事件里 exec_start 被服务端白名单丢掉 → 2 条
654
+ assert.equal(diagnosis.evidence.eventCount, 2)
655
+ assert.equal(diagnosis.timeline.length, 2)
656
+ assert.match(diagnosis.logTail, /stdout line 1/)
657
+ assert.deepEqual(diagnosis.suggestions, [])
658
+ })
659
+
660
+ await test('POST /run-command:反推等价 docker run(只生成文本,不执行)', async () => {
661
+ const res = await call('POST', '/run-command', { target: '本机', id: 'shop-web-1' })
662
+ assert.equal(res.status, 200)
663
+ const recreate = res.body.recreate
664
+ assert.equal(recreate.name, 'shop-web-1')
665
+ assert.match(recreate.command, /^docker run -d/)
666
+ // 端口 / 网络 / env 都还原
667
+ assert.match(recreate.command, /-p 0\.0\.0\.0:8080:80\/tcp/)
668
+ assert.match(recreate.command, /--network shop_default/)
669
+ assert.match(recreate.command, /-e DB_PASSWORD=secret-value/)
670
+ // hostname 等于容器 ID 前 12 位 = docker 默认值,不该写进命令
671
+ assert.equal(recreate.command.includes('--hostname'), false)
672
+ // 敏感键名进 secretKeys(值不改写),ENTRYPOINT 进 omitted
673
+ assert.deepEqual(recreate.secretKeys, ['DB_PASSWORD'])
674
+ assert.equal(recreate.omitted.some((line) => line.includes('ENTRYPOINT')), true)
675
+ assert.equal(recreate.warnings.length > 0, true)
676
+ })
677
+
678
+ await test('POST /run-command:缺 id 400 / 非法 id 400(不进 docker)', async () => {
679
+ const missing = await call('POST', '/run-command', { target: '本机' })
680
+ assert.equal(missing.status, 400)
681
+ assert.match(missing.body.error, /id 必填/)
682
+ const badId = await call('POST', '/run-command', { target: '本机', id: 'shop-web-1; rm -rf /' })
683
+ assert.equal(badId.status, 400)
684
+ assert.match(badId.body.error, /container 含非法字符/)
685
+ })
686
+
687
+ await test('POST /diagnose:缺 id 400 / 非法 id 400 / 非法 since 400(都在进 docker 之前)', async () => {
688
+ const missing = await call('POST', '/diagnose', { target: '本机' })
689
+ assert.equal(missing.status, 400)
690
+ assert.match(missing.body.error, /id 必填/)
691
+ const badId = await call('POST', '/diagnose', { target: '本机', id: 'shop-web-1; rm -rf /' })
692
+ assert.equal(badId.status, 400)
693
+ assert.match(badId.body.error, /container 含非法字符/)
694
+ // since 与 docker_logs 同一口径(D45):非法值回 400,而不是被外层 catch 写成 500
695
+ const badSince = await call('POST', '/diagnose', { target: '本机', id: 'shop-web-1', since: '昨天' })
696
+ assert.equal(badSince.status, 400)
697
+ assert.match(badSince.body.error, /since 无法识别/)
698
+ })
699
+
500
700
  await test('GET /logs/stream:SSE 事件序列(line stdout/stderr → end)', async () => {
501
701
  const res = await call('GET', '/logs/stream?target=本机&id=shop-web-1&tail=50')
502
702
  assert.equal(res.status, 200)
@@ -867,6 +1067,30 @@ await test('开启变更后:网络 / 卷的 remove 与 prune 放行', async ()
867
1067
  }
868
1068
  })
869
1069
 
1070
+ await test('POST /system-df:汇总 + 明细(计数是字符串→number;totals 不进 HTTP 契约)', async () => {
1071
+ const res = await call('POST', '/system-df', { target: '本机' })
1072
+ assert.equal(res.status, 200)
1073
+ const disk = res.body.disk
1074
+ assert.equal(disk.summary.length, 4)
1075
+ assert.deepEqual(disk.summary[0], { type: 'Images', totalCount: 14, active: 10, size: '2.011GB', reclaimable: '1.134GB (56%)' })
1076
+ // 明细按独占大小降序:nginx 142MB 在 alpine 0B 前面
1077
+ assert.equal(disk.images[0].repository, 'nginx')
1078
+ assert.equal(disk.images[0].containers, 1)
1079
+ assert.equal(disk.images[1].containers, 0)
1080
+ assert.deepEqual(disk.volumes.map((row) => row.name), ['pgdata', 'empty-vol'])
1081
+ assert.equal(disk.buildCache[0].inUse, false)
1082
+ assert.deepEqual(disk.truncated, { images: false, volumes: false, buildCache: false })
1083
+ assert.equal(disk.degraded, false)
1084
+ assert.equal(disk.degradedReason, null)
1085
+ // totals 是宿主内部字段(工具层用它报「还有多少」),契约 §2.1 里没有它
1086
+ assert.equal(Object.hasOwn(disk, 'totals'), false)
1087
+ })
1088
+
1089
+ await test('POST /system-df:只读路由(GET 405)', async () => {
1090
+ const wrongMethod = await call('GET', '/system-df')
1091
+ assert.equal(wrongMethod.status, 405)
1092
+ })
1093
+
870
1094
  await test('agent 工具:docker_networks / docker_volumes 列清单(变更类不注册工具)', async () => {
871
1095
  const netTool = state.tools.find((item) => item.name === 'docker_networks')
872
1096
  assert.ok(netTool !== undefined)
@@ -934,6 +1158,10 @@ await test('agent 工具:每个工具的 render 都能跑,且输出里不出
934
1158
  ['docker_images', { target: '本机' }],
935
1159
  ['docker_image_inspect', { target: '本机', ref: 'nginx:1.27' }],
936
1160
  ['docker_events', { target: '本机' }],
1161
+ ['docker_top', { target: '本机', id: 'shop-web-1' }],
1162
+ ['docker_diagnose', { target: '本机', id: 'shop-web-1' }],
1163
+ ['docker_run_command', { target: '本机', id: 'shop-web-1' }],
1164
+ ['docker_disk_usage', { target: '本机' }],
937
1165
  ['docker_networks', { target: '本机' }],
938
1166
  ['docker_volumes', { target: '本机' }],
939
1167
  // 开关门禁的那几个:本用例跑在 allowMutations / allowExec 打开之后,所以它们此刻也注册着。
@@ -957,6 +1185,57 @@ await test('agent 工具:每个工具的 render 都能跑,且输出里不出
957
1185
  }
958
1186
  })
959
1187
 
1188
+ await test('agent 工具:docker_disk_usage / docker_run_command 的结构化返回', async () => {
1189
+ const diskTool = state.tools.find((item) => item.name === 'docker_disk_usage')
1190
+ assert.ok(diskTool !== undefined, 'docker_disk_usage 未注册')
1191
+ const disk = await diskTool.execute({ target: '本机' })
1192
+ assert.equal(disk.summary.length, 4)
1193
+ assert.equal(disk.images[0].containers, 1)
1194
+ // 工具层把截断前的真实条数带出来(schema 要求每类都有 totals)
1195
+ assert.deepEqual(disk.totals, { images: 2, volumes: 2, buildCache: 1 })
1196
+ // degradedReason 为 null 时**省略这个键**(output.schema 没有 null 类型)
1197
+ assert.equal(Object.hasOwn(disk, 'degradedReason'), false)
1198
+ assert.equal(disk.degraded, false)
1199
+
1200
+ const cmdTool = state.tools.find((item) => item.name === 'docker_run_command')
1201
+ assert.ok(cmdTool !== undefined, 'docker_run_command 未注册')
1202
+ const cmd = await cmdTool.execute({ target: '本机', id: 'shop-web-1' })
1203
+ assert.match(cmd.command, /^docker run -d/)
1204
+ assert.deepEqual(cmd.secretKeys, ['DB_PASSWORD'])
1205
+ assert.equal(cmd.omitted.some((line) => line.includes('ENTRYPOINT')), true)
1206
+ })
1207
+
1208
+ await test('agent 工具:docker_top / docker_diagnose 的结构化返回(可缺字段省略而不是 null)', async () => {
1209
+ const topTool = state.tools.find((item) => item.name === 'docker_top')
1210
+ assert.ok(topTool !== undefined, 'docker_top 未注册')
1211
+ const top = await topTool.execute({ target: '本机', id: 'shop-web-1' })
1212
+ assert.equal(top.name, 'shop-web-1')
1213
+ assert.equal(top.processes.length, 2)
1214
+ assert.equal(typeof top.processes[0].pid, 'string')
1215
+
1216
+ const diagnoseTool = state.tools.find((item) => item.name === 'docker_diagnose')
1217
+ assert.ok(diagnoseTool !== undefined, 'docker_diagnose 未注册')
1218
+ const diagnosis = await diagnoseTool.execute({ target: '本机', id: 'shop-web-1' })
1219
+ assert.equal(diagnosis.verdict.code, 'running-ok')
1220
+ // 批三:工具侧也带历史(不是新工具——历史只是同一份诊断的第二种视图)
1221
+ assert.equal(Array.isArray(diagnosis.history.samples), true)
1222
+ assert.equal(diagnosis.history.samples.length >= 1, true)
1223
+ assert.equal(typeof diagnosis.history.recorded, 'boolean')
1224
+ assert.equal(typeof diagnosis.history.trend.verdict.summary, 'string')
1225
+ // 工具 schema 没有 null 类型:样本里的可缺字段必须是「不出现」
1226
+ for (const sample of diagnosis.history.samples) {
1227
+ for (const value of Object.values(sample)) assert.notEqual(value, null)
1228
+ }
1229
+ // render 压成一行(不逐条铺样本)
1230
+ const rendered = diagnoseTool.output.render({}, diagnosis)[0].text
1231
+ assert.match(rendered, /历史 \d+ 次/)
1232
+ assert.equal(rendered.includes('2026-11-03T'), false)
1233
+ // output.schema 没有 null 类型:可缺字段必须「不出现」,写 null 会被宿主校验挡掉整条结果
1234
+ for (const value of Object.values(diagnosis.evidence)) assert.notEqual(value, null)
1235
+ assert.equal(Object.hasOwn(diagnosis.evidence, 'memoryLimitBytes'), false)
1236
+ assert.match(diagnosis.logTail, /stdout line 1/)
1237
+ })
1238
+
960
1239
  await test('agent 工具:docker_ps 经假 CLI 返回容器', async () => {
961
1240
  const tool = state.tools.find((item) => item.name === 'docker_ps')
962
1241
  assert.ok(tool !== undefined)
@@ -1223,7 +1502,7 @@ await test('重新启用:工具(含能力开关项)、公告、数据路
1223
1502
  assert.equal(res.status, 200)
1224
1503
  assert.equal(res.body.config.enabled, true)
1225
1504
  const names = toolNames()
1226
- for (const readonly of ['docker_targets', 'docker_ps', 'docker_inspect', 'docker_logs', 'docker_logs_grep', 'docker_stats', 'docker_images', 'docker_image_inspect', 'docker_events', 'docker_networks', 'docker_volumes']) {
1505
+ for (const readonly of ['docker_targets', 'docker_ps', 'docker_inspect', 'docker_logs', 'docker_logs_grep', 'docker_stats', 'docker_images', 'docker_image_inspect', 'docker_events', 'docker_top', 'docker_diagnose', 'docker_run_command', 'docker_networks', 'docker_volumes', 'docker_disk_usage']) {
1227
1506
  assert.equal(names.includes(readonly), true, `缺少只读工具 ${readonly}`)
1228
1507
  }
1229
1508
  // allowMutations / allowExec 在此前的用例里已打开:重启用后对应工具一并回来
@@ -1410,6 +1689,8 @@ for (const result of results) {
1410
1689
  console.log(`\n[dsh-docker] route-smoke: ${String(results.length - failed)}/${String(results.length)} 通过`)
1411
1690
  // 清理假 CLI 临时目录(D25 顺带):此前每次运行在 os.tmpdir() 留一份
1412
1691
  try { rmSync(dir, { recursive: true, force: true }) } catch { /* 忽略 */ }
1692
+ // 诊断历史的临时目录(与假 CLI 的目录同一收尾,别在 os.tmpdir() 留垃圾)
1693
+ try { rmSync(historyDir, { recursive: true, force: true }) } catch { /* 忽略 */ }
1413
1694
  clearTimeout(watchdog)
1414
1695
  // 成功路径也要显式退出(D121):主体结束后仍有周期句柄漏着的话,
1415
1696
  // 关掉看门狗就再也没人兜底了 —— 直接 exit 既保证退出也省掉排空等待