@hyzyn/dsh-docker 0.6.3 → 0.7.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/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { definePlugin } from '@hyzyn/dsh-kit';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
- import { DockerApi, assertBin, assertImageRef, assertRef, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, } from './docker.js';
4
+ import * as dns from 'node:dns';
5
+ import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, } from './docker.js';
5
6
  import { RemoteExec, setCredentialResolver, sshTarget } from './ssh-exec.js';
6
7
  const TARGET_SCHEMA = z.object({
7
8
  name: z.string().required(),
@@ -21,7 +22,10 @@ const TARGET_SCHEMA = z.object({
21
22
  const HOST_KEY_SCHEMA = z.object({
22
23
  host: z.string().required(),
23
24
  port: z.natural().max(65535).default(22),
24
- fingerprint: z.string().required(),
25
+ /** 同一 host:port 的全部主机密钥指纹(rsa / ed25519 等各一条)。 */
26
+ fingerprints: z.array(z.string()).default([]),
27
+ /** 旧版单指纹字段:仅作迁移输入(sanitizeHostKeys 会并进 fingerprints)。 */
28
+ fingerprint: z.string().default(''),
25
29
  });
26
30
  const DOCKER_SETTINGS_SCHEMA = z.object({
27
31
  enabled: z.boolean().default(true),
@@ -51,12 +55,25 @@ const KNOWN_CONFIG_KEYS = new Set([
51
55
  'hostKeys',
52
56
  /** 显式清空全部目标的确认位(见 POST /config 的空数组防丢保护)。 */
53
57
  'clearTargets',
58
+ /** 显式删除 SSH 主机密钥记录:[{host, port}](hostKeys 是并集合并,删除必须显式)。 */
59
+ 'hostKeysRemove',
54
60
  ]);
55
61
  /* ------------------------------------------------------------------ *
56
62
  * 常量
57
63
  * ------------------------------------------------------------------ */
58
64
  const ROUTE_PREFIX = '/api/dsh-docker';
59
65
  const BODY_LIMIT = 1024 * 1024;
66
+ /** 变更类子路由(D32):要求同源证明(Origin 或 Sec-Fetch-Site: same-origin)。 */
67
+ const MUTATION_SUBROUTES = new Set([
68
+ '/action',
69
+ '/images/remove',
70
+ '/images/prune',
71
+ '/networks/remove',
72
+ '/networks/prune',
73
+ '/volumes/remove',
74
+ '/volumes/prune',
75
+ '/exec',
76
+ ]);
60
77
  const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标)、docker_ps(列容器,含 compose 项目与服务;**target 传 `*` 可一次列出所有目标**)、docker_attention(**需关注汇总**:unhealthy / 反复重启 / OOM / 非零退出 / 僵死,同样支持 `*` 跨目标)、docker_inspect(容器详情)、docker_logs(日志快照)、docker_stats(CPU/内存/IO 快照)、docker_images(镜像列表)、docker_image_inspect(镜像详情 + 构建历史)、docker_events(容器事件快照,见面板容器列表的「活动」条)、docker_networks(网络列表)、docker_volumes(卷列表);排障推荐顺序:不确定从哪台/哪个容器看起时先 docker_attention(可 `*` 跨目标)→ docker_ps → docker_logs → docker_inspect → docker_stats → docker_events,镜像排查用 docker_images → docker_image_inspect。docker_action(容器生命周期)、docker_image_remove(删镜像)、docker_image_prune(清理 dangling)、docker_image_pull(拉取镜像)、docker_exec 仅在用户打开对应开关后可用,执行前须确认目标,破坏性操作(容器 remove / 镜像删除与清理)要向用户复述后果。网络 / 卷的删除与 prune 目前只提供面板按钮(HTTP 端点),没有对应的 agent 工具——不要在 agent 侧绕过面板做这些变更。docker socket 等价于目标主机的 root 权限,不要在用户未明确要求时执行变更操作。';
61
78
  /**
62
79
  * SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
@@ -68,9 +85,81 @@ export function sseFrame(event, data) {
68
85
  /** SSE 心跳间隔(毫秒):注释帧只保活,客户端 EventSource 会忽略。 */
69
86
  const SSE_HEARTBEAT_MS = 15_000;
70
87
  /** HTTP 路由的 loopback 信任围栏(与 tty / dsh-mcp 同思路)。 */
88
+ /*
89
+ * 环回地址判定(D31):接受 127/8 全段(BSD/Linux 惯例——整个 127.0.0.0/8 都是
90
+ * 环回,此前只认 127.0.0.1 一个字面量)与 IPv6 等价形式(::1、::ffff: 映射)。
91
+ */
92
+ function isLoopbackAddress(address) {
93
+ if (address === undefined || address === '')
94
+ return false;
95
+ let text = address.toLowerCase();
96
+ if (text.startsWith('::ffff:'))
97
+ text = text.slice(7);
98
+ if (text === '::1')
99
+ return true;
100
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
101
+ return v4 !== null && v4[1] === '127';
102
+ }
103
+ /** 别名 Host 解析结果的缓存与超时(D110):请求路径里的 DNS 不该每请求都打一次,也不该无限等。 */
104
+ const HOST_LOOPBACK_TTL_MS = 60_000;
105
+ const HOST_LOOPBACK_CACHE_MAX = 64;
106
+ const HOST_LOOPBACK_TIMEOUT_MS = 500;
107
+ const hostLoopbackCache = new Map();
108
+ /**
109
+ * 解析一个主机名是否指向本机(D110)。带 500ms 超时与 60s 的 LRU(别名部署下每个请求都要过一次)。
110
+ * **失败与超时不缓存**:解析器恢复后要立刻生效,而不是把一次抖动钉 60 秒。
111
+ */
112
+ async function lookupHostLoopback(host) {
113
+ const cached = hostLoopbackCache.get(host);
114
+ if (cached !== undefined && Date.now() - cached.at <= HOST_LOOPBACK_TTL_MS)
115
+ return cached.loopback;
116
+ let timer = null;
117
+ try {
118
+ const records = await Promise.race([
119
+ dns.promises.lookup(host, { all: true }),
120
+ new Promise((_resolve, reject) => {
121
+ timer = setTimeout(() => reject(new Error(`DNS 解析超时(>${String(HOST_LOOPBACK_TIMEOUT_MS)}ms)`)), HOST_LOOPBACK_TIMEOUT_MS);
122
+ timer.unref?.();
123
+ }),
124
+ ]);
125
+ const loopback = records.some((record) => isLoopbackAddress(record.address));
126
+ if (hostLoopbackCache.size >= HOST_LOOPBACK_CACHE_MAX) {
127
+ const oldest = hostLoopbackCache.keys().next().value;
128
+ if (oldest !== undefined)
129
+ hostLoopbackCache.delete(oldest);
130
+ }
131
+ hostLoopbackCache.set(host, { at: Date.now(), loopback });
132
+ return loopback;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ finally {
138
+ if (timer !== null)
139
+ clearTimeout(timer);
140
+ }
141
+ }
142
+ /**
143
+ * Host 是否指向本机(D31):字面量环回直接判;主机名 / /etc/hosts 别名走一次带超时的
144
+ * DNS 解析。判不出来就拒绝——围栏宁可误拦一个怪别名,不能放行一个能解析到公网的 Host。
145
+ */
146
+ async function hostResolvesToLoopback(hostname) {
147
+ const host = hostname.toLowerCase().replace(/\.$/, '');
148
+ if (host === 'localhost' || host.endsWith('.localhost') || isLoopbackAddress(host))
149
+ return true;
150
+ return await lookupHostLoopback(host);
151
+ }
152
+ /**
153
+ * loopback 信任围栏(D31):字面量环回(绝大多数请求)**同步**判定——保持
154
+ * 「请求进来即建流」的原有时序(SSE 测试与 EventSource 都依赖第一拍就写头);
155
+ * 只有主机名 / /etc/hosts 别名才走异步 DNS 确认。
156
+ *
157
+ * **来源检查必须在解析 Host 之前**(D80):别名主机名(`127.0.0.1.nip.io`、`/etc/hosts` 里
158
+ * 的别名)走的是异步分支,若在那里提前 return,`Sec-Fetch-Site` 与 Origin 两段检查会被
159
+ * 整段跳过 —— 围栏等于没设,跨站页面就能写 `/config`(它不要求同源证明)。
160
+ */
71
161
  function isLoopbackHttp(req) {
72
- const address = req.socket.remoteAddress;
73
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
162
+ if (!isLoopbackAddress(req.socket.remoteAddress))
74
163
  return false;
75
164
  const host = req.headers.host;
76
165
  if (typeof host !== 'string')
@@ -82,15 +171,44 @@ function isLoopbackHttp(req) {
82
171
  catch {
83
172
  return false;
84
173
  }
85
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
86
- return false;
87
174
  if (req.headers['sec-fetch-site'] === 'cross-site')
88
175
  return false;
89
176
  const origin = req.headers.origin;
90
- if (origin === undefined)
177
+ if (origin !== undefined) {
178
+ let sameOrigin = false;
179
+ try {
180
+ sameOrigin = new URL(origin).host === hostUrl.host;
181
+ }
182
+ catch {
183
+ sameOrigin = false;
184
+ }
185
+ if (!sameOrigin)
186
+ return false;
187
+ }
188
+ const hostname = hostUrl.hostname.toLowerCase().replace(/\.$/, '');
189
+ if (hostname === 'localhost' || hostname.endsWith('.localhost') || isLoopbackAddress(hostname))
91
190
  return true;
191
+ return hostResolvesToLoopback(hostname);
192
+ }
193
+ /**
194
+ * 「同源证明」(D32):变更类与长流端点要求请求带 Origin(浏览器 fetch 对
195
+ * cross-site 一定带)或 Sec-Fetch-Site: same-origin 之一。恶意页面可以用
196
+ * `<img src="GET /images/pull/stream?...">` 触发副作用 / 拉起 docker 子进程,
197
+ * 而旧 Safari / 部分 WebView 既不发 Origin 也不发 Sec-Fetch-Site——这两类端点
198
+ * 对「无来源证明」的请求拒绝;只读端点维持 loopback-only 的原信任模型。
199
+ */
200
+ function hasSameOriginProof(req) {
201
+ const site = req.headers['sec-fetch-site'];
202
+ if (typeof site === 'string' && site === 'same-origin')
203
+ return true;
204
+ const origin = req.headers.origin;
205
+ if (typeof origin !== 'string' || origin === '')
206
+ return false;
207
+ const host = req.headers.host;
208
+ if (typeof host !== 'string')
209
+ return false;
92
210
  try {
93
- return new URL(origin).host === hostUrl.host;
211
+ return new URL(origin).host === host;
94
212
  }
95
213
  catch {
96
214
  return false;
@@ -155,38 +273,106 @@ export function sanitizeTargets(input) {
155
273
  }
156
274
  return out;
157
275
  }
158
- /** 清洗一份 hostKeys 输入。 */
276
+ /**
277
+ * 清洗一份 hostKeys 输入(settings 存储 / 热更新 / 种子复制共用)。
278
+ *
279
+ * 同时接受两种形状(D03):
280
+ * - tty 0.19.0+ 的 `{host, port, fingerprints: [...]}`(多指纹集合);
281
+ * - docker 0.6.x 自己落盘的 `{host, port, fingerprint}`(迁移输入)。
282
+ * host 统一 trim + 小写(与 tty 的落盘口径一致,否则种子命中与否取决于大小写),
283
+ * 同一 host:port 的多条记录合并成一组指纹。
284
+ */
159
285
  export function sanitizeHostKeys(input) {
160
286
  if (!Array.isArray(input))
161
287
  return undefined;
162
- const out = [];
288
+ const byKey = new Map();
163
289
  for (const raw of input) {
164
290
  if (typeof raw !== 'object' || raw === null)
165
291
  continue;
166
292
  const item = raw;
167
- const host = typeof item.host === 'string' ? item.host.trim() : '';
168
- const fingerprint = typeof item.fingerprint === 'string' ? item.fingerprint.trim() : '';
169
- if (host === '' || fingerprint === '')
170
- continue;
293
+ const host = typeof item.host === 'string' ? item.host.trim().toLowerCase() : '';
171
294
  const port = typeof item.port === 'number' && Number.isInteger(item.port) && item.port >= 1 && item.port <= 65535 ? item.port : 22;
172
- out.push({ host, port, fingerprint });
295
+ const list = Array.isArray(item.fingerprints) ? item.fingerprints : [];
296
+ const single = typeof item.fingerprint === 'string' && item.fingerprint.trim() !== '' ? [item.fingerprint.trim()] : [];
297
+ const fingerprints = [...new Set([
298
+ ...list.filter((fp) => typeof fp === 'string' && fp.trim() !== '').map((fp) => fp.trim()),
299
+ ...single,
300
+ ])];
301
+ if (host === '' || fingerprints.length === 0)
302
+ continue;
303
+ const key = `${host}:${String(port)}`;
304
+ const existing = byKey.get(key);
305
+ if (existing !== undefined) {
306
+ existing.fingerprints = [...new Set([...existing.fingerprints, ...fingerprints])];
307
+ continue;
308
+ }
309
+ byKey.set(key, { host, port, fingerprints });
173
310
  }
174
- return out;
311
+ return [...byKey.values()];
312
+ }
313
+ /**
314
+ * hostKeys **并集**合并(D10):客户端表单快照回传的表不得整表覆盖 TOFU 运行期
315
+ * 新增的记录——面板一次无关保存就把钉扎回退掉,指纹变更检测随之失效。按
316
+ * host:port 合并指纹集合;删除某条记录走显式的 `hostKeysRemove`。
317
+ */
318
+ export function mergeHostKeys(base, incoming) {
319
+ const byKey = new Map();
320
+ for (const record of base) {
321
+ byKey.set(`${record.host}:${String(record.port)}`, { host: record.host, port: record.port, fingerprints: [...record.fingerprints] });
322
+ }
323
+ for (const record of sanitizeHostKeys(incoming) ?? []) {
324
+ const key = `${record.host}:${String(record.port)}`;
325
+ const existing = byKey.get(key);
326
+ if (existing === undefined) {
327
+ byKey.set(key, { host: record.host, port: record.port, fingerprints: [...record.fingerprints] });
328
+ continue;
329
+ }
330
+ existing.fingerprints = [...new Set([...existing.fingerprints, ...record.fingerprints])];
331
+ }
332
+ return [...byKey.values()];
175
333
  }
176
334
  /**
177
335
  * 合并凭证:配置卡片从不回显密码 / 口令(只回 passwordSet),因此浏览器提交的
178
336
  * targets 里往往**没有** password/passphrase 字段。按目标名把已有值补回来,
179
337
  * 避免「改个名字就把密码清了」。(显式传空字符串仍然按清空处理。)
338
+ *
339
+ * 改名的目标按**连接身份**(book / host / port / username / auth / keyPath)认领
340
+ * 旧凭证(D15):只按名字找的话,改名 = 凭证凭空消失。身份对不上就不继承——
341
+ * 「删一个目标、另建一个无关目标」不应该串密码,宁缺勿错。
180
342
  */
181
343
  export function mergeTargetSecrets(prev, incoming) {
182
344
  if (!Array.isArray(incoming))
183
345
  return incoming;
346
+ const incomingNames = new Set();
347
+ for (const raw of incoming) {
348
+ if (typeof raw !== 'object' || raw === null)
349
+ continue;
350
+ const name = raw.name;
351
+ if (typeof name === 'string' && name.trim() !== '')
352
+ incomingNames.add(name.trim());
353
+ }
354
+ // 名字没出现在新表里的旧目标 = 改名 / 删除的候选
355
+ const renamed = prev.filter((target) => !incomingNames.has(target.name));
184
356
  return incoming.map((raw) => {
185
357
  if (typeof raw !== 'object' || raw === null)
186
358
  return raw;
187
359
  const item = raw;
188
360
  const name = typeof item.name === 'string' ? item.name.trim() : '';
189
- const before = prev.find((target) => target.name === name);
361
+ let before = prev.find((target) => target.name === name);
362
+ if (before === undefined && renamed.length > 0) {
363
+ const book = typeof item.book === 'string' ? item.book.trim() : '';
364
+ const host = typeof item.host === 'string' ? item.host.trim() : '';
365
+ const username = typeof item.username === 'string' ? item.username.trim() : '';
366
+ const keyPath = typeof item.keyPath === 'string' ? item.keyPath.trim() : '';
367
+ const auth = item.auth === 'key' || item.auth === 'password' ? item.auth : 'agent';
368
+ const port = typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22;
369
+ before = renamed.find((target) => (target.book ?? '') === book
370
+ && target.host === host
371
+ && (target.port ?? 22) === port
372
+ && target.username === username
373
+ && (target.auth ?? 'agent') === auth
374
+ && target.keyPath === keyPath);
375
+ }
190
376
  if (before === undefined)
191
377
  return item;
192
378
  const next = { ...item };
@@ -348,7 +534,7 @@ const plugin = definePlugin({
348
534
  const ttySeed = () => {
349
535
  const map = new Map();
350
536
  for (const record of readTtyHostKeys(settingsApi))
351
- map.set(`${record.host}:${String(record.port)}`, record.fingerprint);
537
+ map.set(`${record.host}:${String(record.port)}`, record.fingerprints);
352
538
  return map;
353
539
  };
354
540
  const persistHostKeys = (records) => {
@@ -361,21 +547,35 @@ const plugin = definePlugin({
361
547
  };
362
548
  const hostKeyStore = {
363
549
  get(host, port) {
364
- const own = live.hostKeys.find((record) => record.host === host && record.port === port);
365
- if (own !== undefined)
366
- return own.fingerprint;
367
- // tty 已确认过的主机不再重复确认:种子命中即视为可信,并复制进本插件的记录
368
- const seeded = ttySeed().get(`${host}:${String(port)}`);
369
- if (seeded !== undefined) {
370
- live.hostKeys = [...live.hostKeys, { host, port, fingerprint: seeded }];
550
+ // host 键统一 trim + 小写(D03):tty 落盘时把 host 小写化,比较口径必须一致
551
+ const key = host.trim().toLowerCase();
552
+ const own = live.hostKeys.find((record) => record.host === key && record.port === port);
553
+ if (own !== undefined && own.fingerprints.length > 0)
554
+ return own.fingerprints;
555
+ // tty 已确认过的主机不再重复确认:种子命中即视为可信,并**整组**复制进本插件的
556
+ // 记录(D03)——此前只认单数 fingerprint 字段,tty 0.19.0 改存 fingerprints[] 后
557
+ // 种子恒为空,对 tty 钉扎过的主机会静默重新 TOFU(指纹变更不再拒绝)。
558
+ const seeded = ttySeed().get(`${key}:${String(port)}`);
559
+ if (seeded !== undefined && seeded.length > 0) {
560
+ live.hostKeys = [...live.hostKeys, { host: key, port, fingerprints: [...seeded] }];
371
561
  persistHostKeys(live.hostKeys);
372
562
  return seeded;
373
563
  }
374
- return undefined;
564
+ return own?.fingerprints;
375
565
  },
376
566
  record(host, port, fingerprint) {
377
- const next = live.hostKeys.filter((record) => !(record.host === host && record.port === port));
378
- next.push({ host, port, fingerprint });
567
+ const key = host.trim().toLowerCase();
568
+ const existing = live.hostKeys.find((record) => record.host === key && record.port === port);
569
+ if (existing !== undefined) {
570
+ // 同一主机的第二把主机密钥(rsa + ed25519):并入集合而不是覆盖(D03)
571
+ if (!existing.fingerprints.includes(fingerprint)) {
572
+ existing.fingerprints = [...existing.fingerprints, fingerprint];
573
+ persistHostKeys(live.hostKeys);
574
+ }
575
+ return;
576
+ }
577
+ const next = live.hostKeys.filter((record) => !(record.host === key && record.port === port));
578
+ next.push({ host: key, port, fingerprints: [fingerprint] });
379
579
  live.hostKeys = next;
380
580
  persistHostKeys(next);
381
581
  },
@@ -434,8 +634,19 @@ const plugin = definePlugin({
434
634
  };
435
635
  /** 解析工具/路由里的 target 参数。 */
436
636
  const pickTarget = (input) => {
437
- if (typeof input === 'string' && input.trim() !== '')
438
- return { name: input.trim() };
637
+ if (typeof input === 'string' && input.trim() !== '') {
638
+ const name = input.trim();
639
+ // `*` 聚合只有 docker_ps / docker_attention 支持(D46):到这里说明是单目标
640
+ // 工具传了 `*`——给专门文案,而不是一句「未知目标:*」
641
+ if (name === '*')
642
+ return { error: '`*`(全部目标)只有 docker_ps / docker_attention 支持;请传具体目标名(docker_targets 列出)' };
643
+ return { name };
644
+ }
645
+ // 类型不对(数字 / 数组 / 对象)必须报错(D34):静默回落到默认目标会让
646
+ // 破坏性操作打错主机的最后一道防线失效
647
+ if (input !== undefined && input !== null && typeof input !== 'string') {
648
+ return { error: 'target 必须是字符串(目标名见 docker_targets)' };
649
+ }
439
650
  const fallback = defaultTargetName();
440
651
  if (fallback !== undefined)
441
652
  return { name: fallback };
@@ -534,6 +745,17 @@ const plugin = definePlugin({
534
745
  hostKeys: live.hostKeys,
535
746
  // 只读复用 tty 连接簿:卡片用它渲染「从连接簿选择」下拉
536
747
  ttyBooks: [...books.keys()],
748
+ /*
749
+ * 连接簿条目 → 解析后的 host:port(**只 name/host/port,凭据永不出宿主**)。
750
+ *
751
+ * 客户端「当前会话主机 ↔ 目标」的匹配要在**连接之前**就能判定:从连接簿打开的
752
+ * SSH 标签,spec 里只有条目名;宿主回显的 `tab.target` 只在**连接成功**后才
753
+ * 有值,而连不上(握手超时 / 主机没开机)恰恰是最需要面板的时候。少了这张表,
754
+ * 面板只能判成「该主机没配目标」,然后沿用上一次选的目标——把另一台主机的容器
755
+ * 显示出来。有了它,按主机配的目标(如目标绑 HS-248 条目、会话走
756
+ * 192.168.80.248 条目)在连接失败时也能对上。
757
+ */
758
+ ttyBookHosts: [...books.entries()].map(([name, spec]) => ({ name, host: spec.host, port: spec.port })),
537
759
  ttyAvailable: books.size > 0,
538
760
  toolsRegistered: registeredNames,
539
761
  };
@@ -592,6 +814,17 @@ const plugin = definePlugin({
592
814
  const controller = new AbortController();
593
815
  let done = false;
594
816
  let heartbeat = null;
817
+ /*
818
+ * 背压(D04):write() 返回 false = socket 写缓冲已满。无视返回值继续写,
819
+ * 慢客户端(后台标签 / 慢链路)+ 话痨容器会让宿主侧缓冲无界增长直至 OOM。
820
+ * 处理:缓冲已满时把帧暂存进内存队列、等 drain 再续写;队列超过上限视为
821
+ * 客户端事实上已死(消费速度跟不上产出),主动收尾——宿主内存上限从
822
+ * 「无界」变成「每条流 ≤ MAX_PENDING_BYTES」。上游(docker logs -f 的
823
+ * stdout)由 finish/clientGone 里的 abort 停掉,不需要逐帧 pause。
824
+ */
825
+ const MAX_PENDING_BYTES = 8 * 1024 * 1024;
826
+ let pendingFrames = [];
827
+ let pendingBytes = 0;
595
828
  const stopHeartbeat = () => {
596
829
  if (heartbeat === null)
597
830
  return;
@@ -604,6 +837,8 @@ const plugin = definePlugin({
604
837
  return;
605
838
  done = true;
606
839
  stopHeartbeat();
840
+ pendingFrames = [];
841
+ pendingBytes = 0;
607
842
  activeStreams.delete(handle);
608
843
  controller.abort();
609
844
  };
@@ -615,28 +850,59 @@ const plugin = definePlugin({
615
850
  stopHeartbeat();
616
851
  activeStreams.delete(handle);
617
852
  controller.abort();
853
+ /*
854
+ * 收尾前把没写完的帧交给 res.end 落地(D83):直接清空队列会把已经入队、还没进
855
+ * socket 的帧(含 end / error 帧)一起丢掉。客户端凭「连接关了但没有 end 帧」
856
+ * 判定异常 → EventSource 自动重连 → **拉取流会把 docker pull 再跑一遍**。
857
+ * 队列本身有 8MB 上限,这里一次 append 不会放大内存。
858
+ */
859
+ const tail = pendingFrames.join('');
860
+ pendingFrames = [];
861
+ pendingBytes = 0;
618
862
  try {
619
- res.end();
863
+ res.end(tail === '' ? undefined : tail);
620
864
  }
621
865
  catch {
622
866
  /* 连接已断开 */
623
867
  }
624
868
  };
625
- const send = (frame) => {
626
- if (done)
627
- return;
869
+ const writeFrame = (frame) => {
628
870
  try {
629
- write(frame);
871
+ return write(frame) !== false;
630
872
  }
631
873
  catch {
632
874
  // 写失败 = 连接已断:与 res close 同一收尾路径
633
875
  clientGone();
876
+ return false;
877
+ }
878
+ };
879
+ /** drain 后续写暂存的帧;中途再遇 false 就停手等下一次 drain。 */
880
+ const flushPending = () => {
881
+ while (!done && pendingFrames.length > 0) {
882
+ const frame = pendingFrames[0];
883
+ if (!writeFrame(frame))
884
+ return;
885
+ pendingBytes -= frame.length;
886
+ pendingFrames.shift();
634
887
  }
635
888
  };
889
+ const send = (frame) => {
890
+ if (done)
891
+ return;
892
+ if (pendingFrames.length === 0 && writeFrame(frame))
893
+ return;
894
+ if (done)
895
+ return;
896
+ pendingFrames.push(frame);
897
+ pendingBytes += frame.length;
898
+ if (pendingBytes > MAX_PENDING_BYTES)
899
+ finish();
900
+ };
636
901
  const sendEvent = (event, data) => send(sseFrame(event, data));
637
902
  const handle = { end: finish };
638
903
  activeStreams.add(handle);
639
904
  res.on?.('close', clientGone);
905
+ res.on?.('drain', flushPending);
640
906
  heartbeat = setInterval(() => send(': ping\n\n'), SSE_HEARTBEAT_MS);
641
907
  heartbeat.unref?.();
642
908
  try {
@@ -654,17 +920,63 @@ const plugin = definePlugin({
654
920
  }
655
921
  };
656
922
  /* ---------- 配置热应用 ---------- */
657
- const applySection = (section) => {
658
- // 目标 / 凭证 / 启用态都可能变:已开的日志流按旧配置在跑,先统一收尾;
659
- // 浏览器侧 EventSource 会自动重连(服务端已停机就停在断开态)
660
- closeAllStreams();
923
+ /**
924
+ * 逐字段比较目标列表(含凭证):凭证变了,已开的流按旧凭据在跑,也要收尾。
925
+ * **顺序无关**(D108):面板上重排一次目标不该被当成「目标变了」而收流 —— 那会把
926
+ * 在途的 docker pull 一起 abort 掉(正是 D09 要消除的「一次白拉」)。
927
+ */
928
+ const sameTargets = (a, b) => {
929
+ if (a.length !== b.length)
930
+ return false;
931
+ const byName = new Map(b.map((item) => [item.name, item]));
932
+ return a.every((item) => {
933
+ const other = byName.get(item.name);
934
+ if (other === undefined)
935
+ return false;
936
+ return item.kind === other.kind
937
+ && item.book === other.book
938
+ && item.host === other.host
939
+ && item.port === other.port
940
+ && item.username === other.username
941
+ && item.auth === other.auth
942
+ && item.keyPath === other.keyPath
943
+ && item.password === other.password
944
+ && item.passphrase === other.passphrase
945
+ && item.agentForward === other.agentForward;
946
+ });
947
+ };
948
+ const applySection = (section, options) => {
949
+ const before = live;
661
950
  // hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
662
951
  const merged = { ...live, ...section };
663
952
  if (section.hostKeys === undefined)
664
953
  merged.hostKeys = live.hostKeys;
665
954
  live = normalizeConfig(merged);
666
- refreshTools();
667
- refreshAnnouncement();
955
+ /*
956
+ * 执行通道相关的键**变了**才收流(D09):TOFU 落盘会走到这里(hostKeys 变化、
957
+ * 其余不变),此前无差别 closeAllStreams 会把刚开的日志 FOLLOW 掐断、把在途的
958
+ * docker pull abort 掉——一次白拉。浏览器侧 EventSource 虽会自动重连,但拉取
959
+ * 不会自己重来。
960
+ */
961
+ // 能力开关被**撤销**时也必须收流(D84):在途的 /images/pull/stream 是写操作,
962
+ // 关掉「允许变更操作」就该立刻停手(旧代码无条件收流,D09 收窄条件时漏了这条)。
963
+ const mutationsRevoked = before.allowMutations && !live.allowMutations;
964
+ if (before.enabled !== live.enabled || before.dockerBin !== live.dockerBin || !sameTargets(before.targets, live.targets) || mutationsRevoked) {
965
+ closeAllStreams();
966
+ }
967
+ // 工具注册面只受启用态 / 能力开关影响;dockerBin 不改变工具清单但影响执行,
968
+ // 一起重注册是幂等的,保守带上。forceRefreshTools 给「保存路径」用:上一次注册
969
+ // 可能只成功了一部分,重存同一份配置也要能重试(D107)。
970
+ if (options?.forceRefreshTools === true
971
+ || before.enabled !== live.enabled
972
+ || before.dockerBin !== live.dockerBin
973
+ || before.allowMutations !== live.allowMutations
974
+ || before.allowExec !== live.allowExec) {
975
+ refreshTools();
976
+ }
977
+ if (before.enabled !== live.enabled || before.announceToAgent !== live.announceToAgent) {
978
+ refreshAnnouncement();
979
+ }
668
980
  console.log(`[dsh-docker] config applied (enabled=${String(live.enabled)}, bin=${live.dockerBin}, targets=${String(live.targets.length)}, allowMutations=${String(live.allowMutations)}, allowExec=${String(live.allowExec)})`);
669
981
  };
670
982
  /* ---------- agent 工具 ---------- */
@@ -676,7 +988,8 @@ const plugin = definePlugin({
676
988
  return '尚未配置任何 Docker 目标(插件配置 → Docker 容器面板 → 目标)。';
677
989
  return 'Docker 目标:' + rows.map((row) => {
678
990
  const state = row.ok === undefined ? '' : row.ok ? ' [可达]' : ` [不可用:${row.error ?? '未知'}]`;
679
- return `\n- ${row.name} (${row.kind}) ${row.label}${state}`;
991
+ const version = row.serverVersion === undefined ? '' : ` docker ${row.serverVersion}`;
992
+ return `\n- ${row.name} (${row.kind}) ${row.label}${version}${state}`;
680
993
  }).join('');
681
994
  };
682
995
  /**
@@ -717,16 +1030,28 @@ const plugin = definePlugin({
717
1030
  };
718
1031
  /** 需关注列表渲染(单目标 / 跨目标共用)。入参同样是**工具扁平形状**,不是 AttentionItem。 */
719
1032
  const renderAttention = (groups) => {
1033
+ // 零目标不是「一切正常」(D53):排障入口给出假阴性比报错更糟——与 docker_ps
1034
+ // 的「尚未配置任何 Docker 目标」兜底同款
1035
+ if (groups.length === 0)
1036
+ return '尚未配置任何 Docker 目标(插件配置 → Docker 容器面板)。';
720
1037
  const total = groups.reduce((sum, group) => sum + (group.items?.length ?? 0), 0);
721
1038
  if (total === 0 && groups.every((group) => group.ok))
722
- return '所有目标上没有需要关注的容器(无 unhealthy / 重启中 / OOM / 非零退出)。';
1039
+ return '所有目标上没有需要关注的容器(无 unhealthy / 反复重启 / OOM / 非零退出 / 僵死)。';
723
1040
  return `需关注容器共 ${String(total)} 个:` + groups.map((group) => {
724
1041
  if (!group.ok)
725
1042
  return `\n\n■ ${group.target}(${group.label})— 不可用:${group.error ?? '未知错误'}`;
726
1043
  const items = group.items ?? [];
727
1044
  if (items.length === 0)
728
1045
  return `\n\n■ ${group.target}(${group.label})— 无异常`;
729
- return `\n\n■ ${group.target}(${group.label})` + items.map((item) => {
1046
+ // 降级信号的措辞要覆盖全部三种成因(D42/D85):inspect 整体失败、候选超出检查预算、
1047
+ // 补捞预算被吃满 —— 都是「权威字段按摘要口径」,而不是单纯的 inspect 失败
1048
+ const warn = group.degraded === true ? '(部分条目未取到权威详情:inspect 失败或候选超出检查预算,OOM / 重启次数等按摘要口径)' : '';
1049
+ // 截断信号(D12):名额按严重度排序后切,超出的不静默
1050
+ const cut = group.truncated === true && group.total !== undefined && group.total > items.length
1051
+ ? `(该目标实际共 ${String(group.total)} 条,已按严重度截断为 ${String(items.length)} 条,可传更大的 limit)`
1052
+ : '';
1053
+ const notes = [warn, cut].filter((part) => part !== '').join(' ');
1054
+ return `\n\n■ ${group.target}(${group.label})${notes === '' ? '' : '\n' + notes}` + items.map((item) => {
730
1055
  const reasons = item.reasons.map((reason) => ATTENTION_LABEL[reason] ?? reason).join(' + ');
731
1056
  const extra = [
732
1057
  item.exitCode === undefined ? '' : `exit=${String(item.exitCode)}`,
@@ -845,10 +1170,24 @@ const plugin = definePlugin({
845
1170
  // 插件禁用(设置卡片关掉「启用插件」)时不注册任何工具:运行期关掉也要立刻生效
846
1171
  if (!live.enabled)
847
1172
  return;
848
- const targetParam = { type: 'string', description: '目标名(docker_targets 列出;只有一个目标时可省略)' };
1173
+ // 文案要跟语义一致(D46):`*` 聚合只有 docker_ps / docker_attention 真支持,
1174
+ // 其余 12 个单目标工具省略时只在「恰好一个目标」时回落、多目标则报 target 必填
1175
+ const targetParam = { type: 'string', description: '目标名(docker_targets 列出;只有一个目标时可省略。`*` 仅 docker_ps / docker_attention 支持,其他工具请传具体目标名)' };
1176
+ /*
1177
+ * 单个工具注册失败不该把整批带下去(D107):以前 add 直接抛,调用方(applySection)
1178
+ * 也就抛了,结果是「旧工具已全拆 + 新工具只注册了一半」,而重存同一份配置又因为
1179
+ * 差异判定不再触发 refreshTools —— 半套状态一直缺到重启。现在逐条兜住并汇总,
1180
+ * 下次配置变更(或保存路径的 forceRefreshTools)会自然重试。
1181
+ */
1182
+ const failures = [];
849
1183
  const add = (toolName, definition) => {
850
- toolDisposers.push(tools.register(definition));
851
- registeredNames.push(toolName);
1184
+ try {
1185
+ toolDisposers.push(tools.register(definition));
1186
+ registeredNames.push(toolName);
1187
+ }
1188
+ catch (error) {
1189
+ failures.push(`${toolName}: ${error instanceof Error ? error.message : String(error)}`);
1190
+ }
852
1191
  };
853
1192
  add('docker_targets', defineTool({
854
1193
  name: 'docker_targets',
@@ -871,6 +1210,7 @@ const plugin = definePlugin({
871
1210
  label: { type: 'string', required: true },
872
1211
  ok: { type: 'boolean' },
873
1212
  error: { type: 'string' },
1213
+ serverVersion: { type: 'string' },
874
1214
  },
875
1215
  },
876
1216
  },
@@ -902,7 +1242,15 @@ const plugin = definePlugin({
902
1242
  continue;
903
1243
  }
904
1244
  const probe = await api.probe();
905
- rows.push({ name: target.name, kind: target.kind, label, ok: probe.ok, ...(probe.ok ? {} : { error: probe.error ?? '未知错误' }) });
1245
+ // serverVersion 随 probe 回传(D48):README 承诺「探测 docker 版本」,
1246
+ // probe() 本来就返回了它,只是这里被丢掉了
1247
+ rows.push({
1248
+ name: target.name,
1249
+ kind: target.kind,
1250
+ label,
1251
+ ok: probe.ok,
1252
+ ...(probe.ok ? { ...(probe.serverVersion === null ? {} : { serverVersion: probe.serverVersion }) } : { error: probe.error ?? '未知错误' }),
1253
+ });
906
1254
  }
907
1255
  return { targets: rows };
908
1256
  },
@@ -982,7 +1330,9 @@ const plugin = definePlugin({
982
1330
  const input = (args ?? {});
983
1331
  const isAll = typeof input.target === 'string' && input.target.trim() === '*';
984
1332
  const toRow = (row) => ({
985
- id: row.id,
1333
+ // 短 ID(D47):docker_attention 已是 shortId,同一字段跨工具宽度要一致
1334
+ //(ps 带 --no-trunc,完整 64 位既与 README 的「短 ID」矛盾也多烧 token)
1335
+ id: row.shortId,
986
1336
  name: row.name,
987
1337
  image: row.image,
988
1338
  state: row.state,
@@ -1028,6 +1378,9 @@ const plugin = definePlugin({
1028
1378
  additionalProperties: false,
1029
1379
  properties: {
1030
1380
  target: { type: 'string' },
1381
+ total: { type: 'number' },
1382
+ truncated: { type: 'boolean' },
1383
+ degraded: { type: 'boolean' },
1031
1384
  items: {
1032
1385
  type: 'array',
1033
1386
  items: {
@@ -1056,6 +1409,9 @@ const plugin = definePlugin({
1056
1409
  label: { type: 'string', required: true },
1057
1410
  ok: { type: 'boolean', required: true },
1058
1411
  error: { type: 'string' },
1412
+ total: { type: 'number' },
1413
+ truncated: { type: 'boolean' },
1414
+ degraded: { type: 'boolean' },
1059
1415
  items: {
1060
1416
  type: 'array',
1061
1417
  items: {
@@ -1083,7 +1439,23 @@ const plugin = definePlugin({
1083
1439
  const v = value;
1084
1440
  if (Array.isArray(v.groups))
1085
1441
  return [{ type: 'text', text: renderAttention(v.groups) }];
1086
- return [{ type: 'text', text: renderAttention([{ target: v.target ?? '?', label: v.target ?? '?', ok: true, items: v.items ?? [] }]) }];
1442
+ /*
1443
+ * 单目标分支也要把 total / truncated / degraded 带上(D100):renderAttention 的
1444
+ * 「实际共 N 条、已截断」与「inspect 降级」两段提示读的就是这三个字段,漏传时
1445
+ * 最常用的单目标调用仍然静默截断 —— D12/D42 想消除的假阴性就还在。
1446
+ */
1447
+ return [{
1448
+ type: 'text',
1449
+ text: renderAttention([{
1450
+ target: v.target ?? '?',
1451
+ label: v.target ?? '?',
1452
+ ok: true,
1453
+ items: v.items ?? [],
1454
+ ...(v.total === undefined ? {} : { total: v.total }),
1455
+ ...(v.truncated === true ? { truncated: true } : {}),
1456
+ ...(v.degraded === true ? { degraded: true } : {}),
1457
+ }]),
1458
+ }];
1087
1459
  },
1088
1460
  },
1089
1461
  async execute(args) {
@@ -1110,7 +1482,12 @@ const plugin = definePlugin({
1110
1482
  label: group.label,
1111
1483
  ok: group.ok,
1112
1484
  ...(group.error === undefined ? {} : { error: group.error }),
1113
- ...(group.data === undefined ? {} : { items: group.data.map(toRow) }),
1485
+ ...(group.data === undefined ? {} : {
1486
+ items: group.data.items.map(toRow),
1487
+ total: group.data.total,
1488
+ ...(group.data.truncated ? { truncated: true } : {}),
1489
+ ...(group.data.degraded ? { degraded: true } : {}),
1490
+ }),
1114
1491
  })),
1115
1492
  };
1116
1493
  }
@@ -1120,8 +1497,14 @@ const plugin = definePlugin({
1120
1497
  const { api } = apiFor(picked.name);
1121
1498
  if (api === undefined)
1122
1499
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1123
- const items = await api.attention(limit === undefined ? undefined : { limit });
1124
- return { target: picked.name, items: items.map(toRow) };
1500
+ const attention = await api.attention(limit === undefined ? undefined : { limit });
1501
+ return {
1502
+ target: picked.name,
1503
+ items: attention.items.map(toRow),
1504
+ total: attention.total,
1505
+ ...(attention.truncated ? { truncated: true } : {}),
1506
+ ...(attention.degraded ? { degraded: true } : {}),
1507
+ };
1125
1508
  },
1126
1509
  }));
1127
1510
  add('docker_inspect', defineTool({
@@ -1161,11 +1544,11 @@ const plugin = definePlugin({
1161
1544
  }));
1162
1545
  add('docker_logs', defineTool({
1163
1546
  name: 'docker_logs',
1164
- description: '读取某个容器的日志尾部(docker logs --tail)。默认 200 行、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
1547
+ description: '读取某个容器的日志尾部(docker logs --tail)。默认行数取插件配置 logTailDefault(出厂 200)、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
1165
1548
  parameters: {
1166
1549
  target: targetParam,
1167
1550
  id: { type: 'string', required: true, description: '容器名或 ID' },
1168
- tail: { type: 'number', description: '尾部行数(1~5000,默认 200)' },
1551
+ tail: { type: 'number', description: '尾部行数(1~5000,默认取配置 logTailDefault;越界值会被静默夹紧到边界)' },
1169
1552
  timestamps: { type: 'boolean', description: 'true 时每行带时间戳' },
1170
1553
  since: { type: 'string', description: '起始时间(docker --since 语法,如 10m、2026-09-09T10:00:00)' },
1171
1554
  },
@@ -1199,7 +1582,10 @@ const plugin = definePlugin({
1199
1582
  const result = await api.logs(input.id, {
1200
1583
  tail: typeof input.tail === 'number' && Number.isInteger(input.tail) ? input.tail : live.logTailDefault,
1201
1584
  timestamps: input.timestamps === true,
1202
- ...(typeof input.since === 'string' ? { since: input.since } : {}),
1585
+ // since 统一口径(D45):与 docker_events 同一个 assertSince——此前 logs
1586
+ // 完全不校验,docker 的参数错误会变成一句不可读的报错。空/纯空白按「未传」处理(D98):
1587
+ // 否则模型传个空串会拿到「since 必填」这种与事实相反的提示。
1588
+ ...(typeof input.since === 'string' && input.since.trim() !== '' ? { since: assertSince(input.since) } : {}),
1203
1589
  });
1204
1590
  return { target: picked.name, id: result.id, text: result.text, truncated: result.truncated };
1205
1591
  },
@@ -1250,6 +1636,11 @@ const plugin = definePlugin({
1250
1636
  const ids = typeof input.ids === 'string'
1251
1637
  ? input.ids.split(',').map((id) => id.trim()).filter((id) => id !== '')
1252
1638
  : [];
1639
+ // 传了 ids 但解析为空(空串 / 纯空白 / 全是逗号)必须报错(D52):静默变成
1640
+ // 「全部容器」会让模型把别的容器数据当成目标容器的
1641
+ if (typeof input.ids === 'string' && input.ids.trim() !== '' && ids.length === 0) {
1642
+ throw new Error('ids 只包含空白:请传容器名/ID(逗号分隔),或干脆省略 ids 表示全部运行中容器');
1643
+ }
1253
1644
  const { api } = apiFor(picked.name);
1254
1645
  if (api === undefined)
1255
1646
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
@@ -1271,7 +1662,7 @@ const plugin = definePlugin({
1271
1662
  }));
1272
1663
  add('docker_events', defineTool({
1273
1664
  name: 'docker_events',
1274
- description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 八类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
1665
+ description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
1275
1666
  parameters: {
1276
1667
  target: targetParam,
1277
1668
  since: { type: 'string', description: '起始时间(docker --since 语法,如 30m、2h;默认 10m)' },
@@ -1311,12 +1702,9 @@ const plugin = definePlugin({
1311
1702
  const picked = pickTarget(input.target);
1312
1703
  if (picked.name === undefined)
1313
1704
  throw new Error(picked.error ?? '无效的 target');
1314
- // since 直接进 argv(不是 shell 字符串),但仍限制字符集:它会被拼进
1315
- // docker 的命令行,留个 ';' 之类只会得到一个难懂的 docker 报错
1316
- const since = typeof input.since === 'string' && input.since.trim() !== '' ? input.since.trim() : '10m';
1317
- if (!/^[0-9]+(ns|us|ms|s|m|h)?$/.test(since) && !/^[0-9]{4}-[0-9]{2}-[0-9]{2}([T ][0-9:.]+(Z|[+-][0-9:]{2,5})?)?$/.test(since)) {
1318
- throw new Error('since 只支持时长(如 30m、2h)或时间戳(如 2026-09-13T10:00:00)');
1319
- }
1705
+ // since 统一口径(D45):assertSince 接受复合 duration(1h30m)——旧白名单
1706
+ // 会拒掉 docker 明明支持的写法
1707
+ const since = assertSince(typeof input.since === 'string' && input.since.trim() !== '' ? input.since : '10m');
1320
1708
  const { api } = apiFor(picked.name);
1321
1709
  if (api === undefined)
1322
1710
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
@@ -1532,7 +1920,9 @@ const plugin = definePlugin({
1532
1920
  description: '对容器执行生命周期操作:start / stop / restart / remove。**破坏性**:remove 会删除容器(数据卷不在其中,但容器配置与可写层丢失),执行前必须向用户确认目标容器。仅当用户在设置里打开「允许变更操作」时可用。',
1533
1921
  parameters: {
1534
1922
  target: targetParam,
1535
- action: { type: 'string', required: true, description: 'start | stop | restart | remove' },
1923
+ // enum 把「四选一」前移到派发前(D51 残留):实测 dsh-tools 的参数 DSL 支持 enum
1924
+ // (不支持的是 minimum/maximum),此前只在执行期拒绝,模型会先浪费一次往返
1925
+ action: { type: 'string', enum: ['start', 'stop', 'restart', 'remove'], required: true, description: 'start | stop | restart | remove(四选一)' },
1536
1926
  id: { type: 'string', required: true, description: '容器名或 ID' },
1537
1927
  },
1538
1928
  output: {
@@ -1647,7 +2037,7 @@ const plugin = definePlugin({
1647
2037
  parameters: {
1648
2038
  target: targetParam,
1649
2039
  ref: { type: 'string', required: true, description: '镜像引用:repository:tag 或 digest' },
1650
- timeoutSec: { type: 'number', description: '超时秒数(10~1800,默认 600)' },
2040
+ timeoutSec: { type: 'number', description: '超时秒数(10~1800,默认 600;越界值会被静默夹紧)' },
1651
2041
  },
1652
2042
  output: {
1653
2043
  schema: {
@@ -1699,7 +2089,7 @@ const plugin = definePlugin({
1699
2089
  target: targetParam,
1700
2090
  id: { type: 'string', required: true, description: '容器名或 ID' },
1701
2091
  command: { type: 'string', required: true, description: '要执行的命令(经容器内 sh -c 执行)' },
1702
- timeoutSec: { type: 'number', description: '超时秒数(1~120,默认取插件配置)' },
2092
+ timeoutSec: { type: 'number', description: '超时秒数(1~120,默认取插件配置 execTimeoutSec;越界值会被静默夹紧)' },
1703
2093
  },
1704
2094
  output: {
1705
2095
  schema: {
@@ -1753,6 +2143,9 @@ const plugin = definePlugin({
1753
2143
  },
1754
2144
  }));
1755
2145
  }
2146
+ if (failures.length > 0) {
2147
+ console.warn(`[dsh-docker] ${String(failures.length)} 个 agent 工具注册失败(下次配置变更会重试):${failures.join(';')}`);
2148
+ }
1756
2149
  };
1757
2150
  // tools 服务(可选):拿到后注册一次,能力开关变化时 refreshTools 重注册
1758
2151
  /*
@@ -1851,7 +2244,18 @@ const plugin = definePlugin({
1851
2244
  const tailParam = params.get('tail');
1852
2245
  const tail = tailParam === null ? live.logTailDefault : Number(tailParam);
1853
2246
  const timestampsParam = params.get('timestamps');
1854
- const since = params.get('since');
2247
+ const sinceParam = params.get('since');
2248
+ // SSE 与快照同一套 since 校验(D45);非法直接 400(此处不在 POST 的 try 内)
2249
+ let since;
2250
+ if (sinceParam !== null && sinceParam.trim() !== '') {
2251
+ try {
2252
+ since = assertSince(sinceParam);
2253
+ }
2254
+ catch (error) {
2255
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2256
+ return;
2257
+ }
2258
+ }
1855
2259
  await openSseStream(res, {
1856
2260
  reason: 'container-exit',
1857
2261
  run: async (sendEvent, signal) => {
@@ -1864,7 +2268,7 @@ const plugin = definePlugin({
1864
2268
  // 与 POST /logs 同一条取值规则:非法/越界交给 DockerApi 内的夹紧
1865
2269
  tail: Number.isInteger(tail) ? tail : live.logTailDefault,
1866
2270
  timestamps: timestampsParam === '1' || timestampsParam === 'true',
1867
- ...(since !== null && since.trim() !== '' ? { since } : {}),
2271
+ ...(since !== undefined ? { since } : {}),
1868
2272
  }, handlers, signal);
1869
2273
  return result.code;
1870
2274
  },
@@ -1923,10 +2327,10 @@ const plugin = definePlugin({
1923
2327
  * 跨 chunk 去重:实测 docker stats 会把**同一个采样渲染两次**(约 500ms 一轮,
1924
2328
  * 0.04 / 0.04 / 0.16 / 0.16 …),两次是逐字节相同的 JSON 且可能落在不同 chunk 里。
1925
2329
  * 不去重的话,客户端 60 点环形缓冲会被重复点占掉一半窗口(实际只剩 ~30 秒)。
1926
- * 用「与上一条完全相同的原始对象」作为去重键:值确实没变的相邻采样会少一个点,
1927
- * 但那一个点的值与上一点相同,趋势形状不受影响,时间轴反而更接近真实采样间隔。
2330
+ * 去重键按**容器**记(D33):此前只与紧邻上一条比较——单容器序列 A A 能去重,
2331
+ * 多容器交错 A B A B 就漏了;按 Name 各记上次值,交错情形同样覆盖。
1928
2332
  */
1929
- let lastRaw = '';
2333
+ const lastRawByName = new Map();
1930
2334
  const handlers = {
1931
2335
  onStdout: (chunk) => {
1932
2336
  pending += chunk;
@@ -1945,10 +2349,14 @@ const plugin = definePlugin({
1945
2349
  if (pending.length > 64 * 1024)
1946
2350
  pending = pending.slice(-4096);
1947
2351
  for (const raw of found) {
1948
- if (raw === lastRaw)
2352
+ const rows = parseStatsJson(raw);
2353
+ if (rows.length === 0)
1949
2354
  continue;
1950
- lastRaw = raw;
1951
- for (const row of parseStatsJson(raw))
2355
+ const key = rows[0]?.name === undefined || rows[0].name === '' ? raw : rows[0].name;
2356
+ if (lastRawByName.get(key) === raw)
2357
+ continue;
2358
+ lastRawByName.set(key, raw);
2359
+ for (const row of rows)
1952
2360
  sendEvent('stats', row);
1953
2361
  }
1954
2362
  },
@@ -2089,7 +2497,15 @@ const plugin = definePlugin({
2089
2497
  kind: 'prefix',
2090
2498
  path: ROUTE_PREFIX,
2091
2499
  handler: async (req, res) => {
2092
- if (!isLoopbackHttp(req)) {
2500
+ const loopback = isLoopbackHttp(req);
2501
+ // 字面量环回同步判定(保持「第一拍就建流」的时序);仅别名主机名才等 DNS
2502
+ if (loopback instanceof Promise) {
2503
+ if (!(await loopback)) {
2504
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
2505
+ return;
2506
+ }
2507
+ }
2508
+ else if (!loopback) {
2093
2509
  writeJson(res, 403, { error: 'forbidden: loopback-only' });
2094
2510
  return;
2095
2511
  }
@@ -2122,7 +2538,7 @@ const plugin = definePlugin({
2122
2538
  writeJson(res, 400, { error: '未知配置项: ' + key });
2123
2539
  return;
2124
2540
  }
2125
- if (key === 'clearTargets')
2541
+ if (key === 'clearTargets' || key === 'hostKeysRemove')
2126
2542
  continue;
2127
2543
  patch[key] = body[key];
2128
2544
  }
@@ -2136,8 +2552,60 @@ const plugin = definePlugin({
2136
2552
  }
2137
2553
  }
2138
2554
  // 凭证补全必须在写盘之前:否则提交的 targets 会把密码清空
2139
- if (patch.targets !== undefined)
2555
+ if (patch.targets !== undefined) {
2556
+ // 读路径会丢弃的条目(重名 / 空名 / 超 64 字符)要有信号(D35):否则
2557
+ // 面板只看到留下来的那条,下一次保存把丢弃结果固化,另一台主机凭空消失
2558
+ const rawTargets = Array.isArray(patch.targets) ? patch.targets.length : 0;
2140
2559
  patch.targets = mergeTargetSecrets(targetsNow(), patch.targets);
2560
+ /*
2561
+ * 计数必须取**真正会丢弃**的那一步(D89):mergeTargetSecrets 是 1:1 的
2562
+ * map(只补凭证、从不删条目),丢弃发生在 sanitizeTargets(读路径与
2563
+ * normalizeConfig 都用它)—— 原来的判据恒为 false,warning 是死代码。
2564
+ */
2565
+ const keptTargets = sanitizeTargets(patch.targets)?.length ?? 0;
2566
+ if (rawTargets > keptTargets) {
2567
+ warning = [warning, `目标列表中有 ${String(rawTargets - keptTargets)} 条无效条目(重名 / 名称为空 / 超过 64 字符)已被丢弃。`].filter((part) => part !== undefined && part !== '').join(' ');
2568
+ }
2569
+ }
2570
+ /*
2571
+ * hostKeys 采用「并集合并 + 显式删除」(D10):客户端表单快照里的
2572
+ * hostKeys 落后于运行期(打开卡片期间可能刚记录了新指纹),整表覆盖
2573
+ * 会把 TOFU 钉扎回退掉。传上来的记录按 host:port 并入现有记录;
2574
+ * 删除某条记录走显式的 hostKeysRemove: [{host, port}]。
2575
+ */
2576
+ if (patch.hostKeys !== undefined || body.hostKeysRemove !== undefined) {
2577
+ let removeRequested = false;
2578
+ const removeKeys = new Set();
2579
+ if (Array.isArray(body.hostKeysRemove)) {
2580
+ for (const raw of body.hostKeysRemove) {
2581
+ if (typeof raw !== 'object' || raw === null)
2582
+ continue;
2583
+ const item = raw;
2584
+ const host = typeof item.host === 'string' ? item.host.trim().toLowerCase() : '';
2585
+ const port = typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22;
2586
+ if (host !== '')
2587
+ removeKeys.add(`${host}:${String(port)}`);
2588
+ }
2589
+ removeRequested = true;
2590
+ }
2591
+ else if (body.hostKeysRemove !== undefined) {
2592
+ // 形状非法不能静默忽略(D109):用户点了「删除」却什么都没发生,且没有任何反馈
2593
+ warning = [warning, 'hostKeysRemove 必须是 [{host, port}] 数组,本条已忽略。'].filter((part) => part !== undefined && part !== '').join(' ');
2594
+ }
2595
+ patch.hostKeys = mergeHostKeys(live.hostKeys, patch.hostKeys ?? []);
2596
+ /*
2597
+ * 删除在并集**之后**生效(D109):同一请求既带 hostKeys 又带 hostKeysRemove 时,
2598
+ * 「删除」必须赢 —— 否则手写请求 / 旧客户端会把刚删掉的指纹又并回来,同样没有提示。
2599
+ */
2600
+ if (removeKeys.size > 0) {
2601
+ const merged = patch.hostKeys;
2602
+ const kept = merged.filter((record) => !removeKeys.has(`${record.host}:${String(record.port)}`));
2603
+ patch.hostKeys = kept;
2604
+ if (removeRequested && kept.length === merged.length) {
2605
+ warning = [warning, '要删除的主机指纹记录不存在(可能已被别处删除)。'].filter((part) => part !== undefined && part !== '').join(' ');
2606
+ }
2607
+ }
2608
+ }
2141
2609
  // 校验必须在**落盘之前**。原先只有 applySection 会做校验,而它跑在
2142
2610
  // scope.update 之后:值不合法的 patch 已经被写进 settings.yaml,随后
2143
2611
  // applySection 抛出,异常穿到宿主 HTTP 层 → 用户收到一个**没有正文的
@@ -2167,7 +2635,17 @@ const plugin = definePlugin({
2167
2635
  }
2168
2636
  // settings/updated 会触发 applySection;无事件时也应用一次(幂等)
2169
2637
  }
2170
- applySection(patch);
2638
+ // applySection 也可能抛(tools.register / systemPrompt.section,D36):
2639
+ // 此时配置已落盘,必须把原因带回给用户,而不是一个空 400/500。
2640
+ // forceRefreshTools:保存路径无条件重注册一次(幂等),这样「上一次只注册了
2641
+ // 一半」的状态能被用户的**重试**修复,而不是必须等到下一次真实配置变化(D107)。
2642
+ try {
2643
+ applySection(patch, { forceRefreshTools: true });
2644
+ }
2645
+ catch (error) {
2646
+ writeJson(res, 500, { error: '配置已保存但应用失败:' + (error instanceof Error ? error.message : String(error)) });
2647
+ return;
2648
+ }
2171
2649
  writeJson(res, 200, { ok: true, config: snapshot(), ...(warning === undefined ? {} : { warning }) });
2172
2650
  return;
2173
2651
  }
@@ -2207,10 +2685,23 @@ const plugin = definePlugin({
2207
2685
  writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
2208
2686
  return;
2209
2687
  }
2688
+ // 四条 SSE 都要有「同源证明」(D32):无 Origin 且无 Sec-Fetch-Site 的
2689
+ // 请求(旧 Safari / 部分 WebView / 裸 curl)在长流端点上拒绝——浏览器
2690
+ // 的 EventSource / fetch 同源请求都会带其中之一
2691
+ if (!hasSameOriginProof(req)) {
2692
+ writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):实时流端点拒绝无来源请求' });
2693
+ return;
2694
+ }
2210
2695
  const params = new URL(req.url ?? '/', 'http://loopback').searchParams;
2211
2696
  await serveStream(req, res, params);
2212
2697
  return;
2213
2698
  }
2699
+ // 变更类端点(写操作)同样要求同源证明(D32);/config 刻意不在名单里:
2700
+ // 它是禁用状态下的唯一恢复入口,跨站 POST 已由 loopback + Origin 比对拦住
2701
+ if (req.method === 'POST' && MUTATION_SUBROUTES.has(sub) && !hasSameOriginProof(req)) {
2702
+ writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):变更端点拒绝无来源请求' });
2703
+ return;
2704
+ }
2214
2705
  if (req.method !== 'POST') {
2215
2706
  writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
2216
2707
  return;
@@ -2250,6 +2741,33 @@ const plugin = definePlugin({
2250
2741
  return;
2251
2742
  }
2252
2743
  const api = built.api;
2744
+ // 引用白名单在路由层先跑一次(D37):非法引用是**客户端**错误,直接 400
2745
+ // 带原因——落到 DockerApi 里才抛的话会被外层 catch 统一写成 500。
2746
+ // D97:`/action` 的 id、`/stats` 的 ids[] 与 `/exec` 的空 command 原先漏在外,
2747
+ // 于是同一类错误在有的路由是 400、有的是 500。
2748
+ try {
2749
+ if ((sub === '/inspect' || sub === '/logs' || sub === '/exec' || sub === '/action') && typeof body.id === 'string')
2750
+ assertRef(body.id, 'container');
2751
+ if (sub === '/stats' && Array.isArray(body.ids)) {
2752
+ for (const id of body.ids)
2753
+ if (typeof id === 'string')
2754
+ assertRef(id, 'container');
2755
+ }
2756
+ if ((sub === '/images/inspect' || sub === '/images/remove') && typeof body.ref === 'string')
2757
+ assertImageRef(body.ref, 'image');
2758
+ if ((sub === '/networks/inspect' || sub === '/networks/remove') && typeof body.name === 'string')
2759
+ assertName(body.name, 'network');
2760
+ if ((sub === '/volumes/inspect' || sub === '/volumes/remove') && typeof body.name === 'string')
2761
+ assertName(body.name, 'volume');
2762
+ if (sub === '/exec' && typeof body.command === 'string' && body.command.trim() === '') {
2763
+ writeJson(res, 400, { error: 'command 不能为空' });
2764
+ return;
2765
+ }
2766
+ }
2767
+ catch (error) {
2768
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2769
+ return;
2770
+ }
2253
2771
  try {
2254
2772
  switch (sub) {
2255
2773
  case '/probe': {
@@ -2261,7 +2779,13 @@ const plugin = definePlugin({
2261
2779
  return;
2262
2780
  }
2263
2781
  case '/attention': {
2264
- writeJson(res, 200, { ok: true, items: await api.attention() });
2782
+ // limit 可由调用方给(D101):面板想一次拿全量时不必被隐式钉在默认 100
2783
+ const limitRaw = body.limit;
2784
+ const limit = typeof limitRaw === 'number' && Number.isInteger(limitRaw)
2785
+ ? Math.min(Math.max(limitRaw, 1), 500)
2786
+ : undefined;
2787
+ const attention = await api.attention(limit === undefined ? undefined : { limit });
2788
+ writeJson(res, 200, { ok: true, items: attention.items, total: attention.total, truncated: attention.truncated, degraded: attention.degraded });
2265
2789
  return;
2266
2790
  }
2267
2791
  case '/inspect': {
@@ -2288,7 +2812,9 @@ const plugin = definePlugin({
2288
2812
  logs: await api.logs(body.id, {
2289
2813
  tail,
2290
2814
  timestamps: body.timestamps === true,
2291
- ...(typeof body.since === 'string' ? { since: body.since } : {}),
2815
+ // `/logs` 此前裸透传(D98):同一参数在 `/logs/stream` 与 agent 工具上是 400/抛错,
2816
+ // 在快照路由上却变成 docker 的参数错误再被写成 500。空/纯空白 = 未传。
2817
+ ...(typeof body.since === 'string' && body.since.trim() !== '' ? { since: assertSince(body.since) } : {}),
2292
2818
  }),
2293
2819
  });
2294
2820
  return;