@hyzyn/dsh-docker 0.13.3 → 0.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -4,9 +4,9 @@ import z from '@deepseek-ai/schemastery';
4
4
  import { definePlugin, hasSameOriginProof, isLoopbackRequestStrict, originProofHint, plainConfig, readSettingsEntry, registerLiteralSection, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools';
6
6
  import { auditCapabilityUse, audited } from './audit.js';
7
- import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
7
+ import { DockerApi, MAX_RESULT_LINES, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
8
8
  import { RemoteExec, sanitizeJumpSpec, sanitizeProxyCommand, setCredentialResolver, sshTarget } from './ssh-exec.js';
9
- import { auditLoadedGrants, attachTierGate, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGranted, capabilityGrantVia, capabilityPaths, createElevationManager, dshHome, sharedGrantStore, } from '@hyzyn/dsh-kit';
9
+ import { auditLoadedGrants, attachTierGate, attachTierRestrict, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGranted, capabilityGrantVia, capabilityPaths, createElevationManager, dshHome, sharedGrantStore, } from '@hyzyn/dsh-kit';
10
10
  /*
11
11
  * 档位闸的分类表(docs/permission-tier-plan.md §2.1):`read` 纯读 / 固定二进制探测
12
12
  * (docker_connect_local 不在能力开关里,见 connectLocal 的注释);`write` 容器与镜像的变更
@@ -100,6 +100,86 @@ const KNOWN_CONFIG_KEYS = new Set([
100
100
  /** 显式删除 SSH 主机密钥记录:[{host, port}](hostKeys 是并集合并,删除必须显式)。 */
101
101
  'hostKeysRemove',
102
102
  ]);
103
+ /* ------------------------------------------------------------------ *
104
+ * 列表分页(docker_ps / docker_images / docker_networks / docker_volumes 共用口径)
105
+ * ------------------------------------------------------------------ */
106
+ /**
107
+ * 一页的默认条数与上限。与 `docker_attention` 的 `limit` **同口径**(1~500 / 默认 100):
108
+ * 「一页多少条」跨工具必须是同一个数,否则模型每换一个列表工具都要重新试一遍。
109
+ *
110
+ * 为什么在**插件侧**切而不是交给 docker CLI 的 `-n` / `--filter`:`listContainers` /
111
+ * `images` / `networks` / `volumes` 的返回契约是「该目标的完整列表」(面板、详情页、公告
112
+ * 都依赖它),在这里切不改那条契约。也**不解决**单条命令 512KB(`maxOutputKb`)的物理
113
+ * 上限——超了照旧抛错(见 `assertComplete`);分页解决的是「结果长到模型读不下去」。
114
+ */
115
+ const PAGE_DEFAULT_LIMIT = 100;
116
+ const PAGE_MAX_LIMIT = 500;
117
+ /**
118
+ * 分页参数的**唯一**归一化点:缺省 / 非整数落到默认值,越界**夹紧**而不是报错
119
+ * (参数 DSL 没有 minimum/maximum,夹紧是这里唯一能保证的语义)。
120
+ */
121
+ function pageWindow(input) {
122
+ const limit = typeof input.limit === 'number' && Number.isInteger(input.limit)
123
+ ? Math.min(Math.max(input.limit, 1), PAGE_MAX_LIMIT)
124
+ : PAGE_DEFAULT_LIMIT;
125
+ const offset = typeof input.offset === 'number' && Number.isInteger(input.offset) && input.offset > 0
126
+ ? input.offset
127
+ : 0;
128
+ return { limit, offset };
129
+ }
130
+ /** 切页的**唯一**实现:`total > offset + limit` 就是「还有下一页」(与 render 的提示同源)。 */
131
+ function paginate(rows, window) {
132
+ const total = rows.length;
133
+ return {
134
+ rows: rows.slice(window.offset, window.offset + window.limit),
135
+ total,
136
+ truncated: total > window.offset + window.limit,
137
+ };
138
+ }
139
+ /** 分页参数的注册表(四条列表共用;与 `docker_attention` 的 limit 描述同款口径)。 */
140
+ const pageParams = {
141
+ limit: { type: 'number', description: '本页最多返回多少条(1~500,默认 100)' },
142
+ offset: { type: 'number', description: '跳过前多少条(默认 0);翻页时传上一页提示里给出的 offset' },
143
+ };
144
+ /**
145
+ * 由返回值 + **入参**拼出 render 用的分页上下文。
146
+ *
147
+ * offset 读的是入参(`execute` 已经用同一个 `pageWindow` 归一化过一遍)——这里只读不写,
148
+ * 也不新增第二个「当前页从哪开始」的真相源。
149
+ */
150
+ function listPage(args, total, truncated) {
151
+ if (total === undefined)
152
+ return undefined;
153
+ const { offset } = pageWindow((args ?? {}));
154
+ return { total, offset, truncated };
155
+ }
156
+ /**
157
+ * 「怎么拿更多」的**唯一**话术(四条列表 render 共用):只在真的还有下一页时出现。
158
+ *
159
+ * 给的是调用方**下一次调用就能用**的参数。不要写成「请调大 maxOutputKb」——agent 改不了
160
+ * 插件设置,这种话术等于把问题丢回给它自己(D105 的判据在这里同样成立)。
161
+ */
162
+ function pagingNote(page, shown) {
163
+ if (page === undefined || page.truncated !== true || page.total <= shown)
164
+ return '';
165
+ return `\n\n共 ${String(page.total)} 条,本页显示 ${String(shown)} 条:继续用 \`offset=${String(page.offset + shown)}\`,或加 \`limit\`(上限 ${String(PAGE_MAX_LIMIT)})`;
166
+ }
167
+ /** 列表头:分页时同时给「共 N 个」与「本页 M 个」——只报一页的条数会被当成全部。 */
168
+ function listHead(noun, target, page, shown) {
169
+ return page !== undefined && page.total !== shown
170
+ ? `目标 ${target} 的${noun}(共 ${String(page.total)} 个,本页 ${String(shown)} 个):`
171
+ : `目标 ${target} 的${noun}(${String(shown)} 个):`;
172
+ }
173
+ /**
174
+ * 空页的两种成因必须分得开:真的没有条目,还是 `offset` 越过了末页。
175
+ * 后者不说清楚,模型会把「翻过头了」当成「这台机器上没有镜像」——D12/D42 那类假阴性。
176
+ */
177
+ function emptyListText(noun, target, page) {
178
+ if (page !== undefined && page.total > 0 && page.offset > 0) {
179
+ return `目标 ${target} 的${noun}共 ${String(page.total)} 个,offset=${String(page.offset)} 已越过末页(改用更小的 offset 重试)。`;
180
+ }
181
+ return `目标 ${target}:没有${noun}。`;
182
+ }
103
183
  /* ------------------------------------------------------------------ *
104
184
  * 常量
105
185
  * ------------------------------------------------------------------ */
@@ -174,16 +254,35 @@ function isTargetFailure(error) {
174
254
  return error !== null && typeof error === 'object' && error[TARGET_FAILURE] === true;
175
255
  }
176
256
  /**
177
- * agent 公告:本插件的能力面与两条 exec 通道的边界。
257
+ * agent 公告:只留**能力索引 + 安全契约**(空态出路 / 能力开关 / 两条 exec 通道的边界 /
258
+ * root 权限警示)。
259
+ *
260
+ * 剩下的事实在别处各有一份,**不要再写回来**(2026-10-25 从 562 压到 250 字符时删的三段):
261
+ * - 「`*`(全部目标)只有 `docker_ps` / `docker_attention` 支持」——两条工具自己的
262
+ * `description` 里各写了一遍(`docker_ps`「target 传 `*` = 一次列出所有目标」、
263
+ * `docker_attention`「target 传 `*` 时跨所有目标聚合」);`targetParam` 与运行期报错
264
+ * 也各有一份。
265
+ * - 排障顺序(`docker_attention` → `docker_ps` → `docker_logs` → 尾部看不到成因再
266
+ * `docker_logs_grep` → `docker_inspect` / `docker_stats`)——散在各工具 `description` 里
267
+ * (「排障入口」/「排障第一步」/「改用 docker_logs_grep」),挨着要用的那个工具读才有用。
268
+ * - 网络 / 卷的增删只在面板——插件压根没有给这两个工具,模型做不了,写了也只是噪音。
269
+ * 这三条的「仍在别处」由 `test/announcement.test.ts` 反向钉住:工具描述被精简掉就红。
270
+ * 长度预算归 `scripts/test/announcement-budget.test.ts` 那份名单(docker 公告并入其中),
271
+ * 本文件不再自带一条长度判据。
178
272
  *
179
- * ⚠️ **「允许 exec」只管本插件的一次性通道**(D163)。卡片「终端」按钮跑的是
273
+ * ⚠️ **「允许 exec」只管本插件的一次性通道**(D163)。面板「终端」按钮跑的是
180
274
  * `docker exec -it` 交互式 shell——它经 tty 的 `ttyTerminal` 承载、**不检查 `allowExec`**
181
275
  * (`client-src/index.js` 的 `openExec` 里没有任何该判断,只读模式下也照常可用),所以这句话
182
276
  * 一旦退回「…docker exec,都需要…才有对应工具与按钮」的笼统说法,就是在**声称一条不成立的
183
277
  * 安全性质**(2026-10-07 用户正是照着旧措辞问「这算是漏洞吗」)。守卫:
184
278
  * `scripts/test/exec-terminal-scope.test.ts`(含「退回旧措辞必须变红」的反例)。
279
+ *
280
+ * ⚠️ **零目标时注册面只剩两条工具**(`ZERO_TARGET_TOOLS`),公告必须给出那条出路:
281
+ * 否则 `docker_targets` 描述里「其他 docker_* 工具的 target 参数取自这里」指向的是一批
282
+ * **此刻并不存在**的工具,模型只能原地打转。守卫:`test/announcement.test.ts`
283
+ * (含「删掉那句必须变红」的反例)。
185
284
  */
186
- const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、**一次性** docker exec(docker_exec 工具与容器详情抽屉「概览」页签的一次性命令框),都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮;「允许 exec」**只管这条一次性通道**——卡片上的「终端」按钮跑的是 `docker exec -it` 交互式 shell,由 tty 终端面板承载、**不受它门控**(只读模式下也能用),别把它当成被关着。agent 侧配套只读工具 docker_targets(列目标;没有任何目标时先调 docker_connect_local 一键加上本机目标)、docker_connect_local(把宿主所在机器加成本机目标并选中,复用已有的本机目标、不重复创建;顺带探测 daemon,失败会说明是 daemon 未启动 / CLI 缺失 / socket 无权访问)、docker_ps(列容器,含 compose 项目与服务;**target 传 `*` 可一次列出所有目标**;端口已按 IPv4/IPv6 双栈归并,`ports` 为空时看 `net`——host 网络容器的端口即宿主机端口)、docker_attention(**需关注汇总**:unhealthy / 反复重启 / OOM / 非零退出 / 僵死,同样支持 `*` 跨目标)、docker_inspect(容器详情)、docker_logs(日志快照,支持 since/until 时间窗)、docker_logs_grep(**日志服务端检索**:在全量历史里找关键词/异常码/请求 ID,可配 since/until 圈时间窗,返回命中行 + 上下文并如实报告 matched/scanned——「报错不在最近尾部」时用它,别拿 docker_logs 的 5000 行硬赌)、docker_stats(CPU/内存/IO 快照)、docker_images(镜像列表)、docker_image_inspect(镜像详情 + 构建历史)、docker_events(容器事件快照,见面板容器列表的「活动」条)、docker_networks(网络列表)、docker_volumes(卷列表);排障推荐顺序:不确定从哪台/哪个容器看起时先 docker_attention(可 `*` 跨目标)→ docker_ps → docker_logs(报错通常在尾部;尾部看不到成因就用 docker_logs_grep 带 pattern/since/until 检索深处)→ 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 权限,不要在用户未明确要求时执行变更操作。';
285
+ const DOCKER_GUIDANCE = '本机装有 dsh-docker 插件(侧栏「容器」)。**默认只读**:启停删容器、镜像删除/清理/拉取、**一次性** docker exec(本插件),都需要用户在设置里打开「允许变更操作」「允许 exec」;「允许 exec」只管这条一次性通道——面板「终端」按钮的 exec -it 由 tty 承载、**不受它门控**。**一个目标都没有时先 `docker_connect_local` 加上本机目标**。docker socket 等价目标主机 root 权限,用户没明确要求别做变更。';
187
286
  /**
188
287
  * SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
189
288
  * 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
@@ -197,6 +296,15 @@ const SSE_HEARTBEAT_MS = 15_000;
197
296
  const SSE_COALESCE_MS = 50;
198
297
  /** 单个通道攒到这个字节数就立刻推一帧:不让一个大 chunk 在窗口里多等一个周期。 */
199
298
  const SSE_COALESCE_MAX_BYTES = 256 * 1024;
299
+ /**
300
+ * 一条日志流最多能带多少个源(`ids=` 的**防御性上限**)。
301
+ *
302
+ * 每个源都是**一条真的 `docker logs -f`**:本机一个子进程、SSH 一条 channel,外加
303
+ * 各自的合帧缓冲。而 `ids` 来自 URL,是客户端可控的量——不设上限时拼一千个条目就能
304
+ * 让宿主一口气起一千个进程(这条流是只读能力,不该成为放大攻击的入口)。32 远高于
305
+ * 「一个 Compose 项目全选」的实际需要,正常视图碰不到它。
306
+ */
307
+ const MAX_LOGS_SOURCES = 32;
200
308
  /**
201
309
  * SSE 分片合帧器(D151):把「一个 stdout chunk 一帧」压成「一个窗口一帧」。
202
310
  *
@@ -574,6 +682,16 @@ const CAPABILITIES = new Map([
574
682
  ['allowMutations', CAP_MUTATIONS],
575
683
  ['allowExec', CAP_EXEC],
576
684
  ]);
685
+ /**
686
+ * 零目标时仍然注册的工具——**空态的唯一出路**。
687
+ *
688
+ * 其余每个工具都必须寻址到一个目标(省略 `target` 时回落「唯一目标」,多目标则必填),
689
+ * 一条目标都没有时它们注册了也只会让模型白试一次、拿到「尚未配置任何 Docker 目标」再
690
+ * 回头调 `docker_targets`;而这两条正是让空态可解释、可自救的入口:一条看有哪些目标,
691
+ * 一条一键加本机。名单之外的 `add()` 在 `live.targets` 为空时直接跳过(见 refreshTools),
692
+ * 配好目标后由 applySection 按目标变化**补注册**,不需要重启。
693
+ */
694
+ const ZERO_TARGET_TOOLS = new Set(['docker_targets', 'docker_connect_local']);
577
695
  /** 未启用时的文案:没授权要说清「怎么授权」,授权了只是没开就说「去开开关」。 */
578
696
  function mutationsOffMessage() {
579
697
  return capabilityGranted(CAP_MUTATIONS)
@@ -1320,8 +1438,14 @@ const plugin = definePlugin({
1320
1438
  * 所以把版本放在这里:客户端读到 <2 就退回旧判据(响应头就算连上),读到 2 才只认
1321
1439
  * `ready`。字段缺失(更老的宿主)由客户端按「发 ready」以外的规则兜底,见
1322
1440
  * client-src/index.js 的 hostSendsReadyFrame。
1441
+ *
1442
+ * 3 = `/logs/stream` 支持多容器单连接(`ids=` + 带 `id` 的分源帧 + source-end /
1443
+ * source-error)。为什么必须再升一版:聚合视图要据此决定「一条连接带全部容器」还是
1444
+ * 「每个容器各开一条 EventSource」——判定错了,新客户端会去解析老宿主永远不发的事件,
1445
+ * 而老客户端拿到多源帧也认不出 `id`(它只读 `d` / `e`,会把几个容器的行混成一本账)。
1446
+ * **≥2 的 ready 语义不变**,新增的是同一条流里的源维度。
1323
1447
  */
1324
- streamProtocol: 2,
1448
+ streamProtocol: 3,
1325
1449
  };
1326
1450
  };
1327
1451
  /* ---------- 活跃 SSE 长流清点 ---------- */
@@ -1637,14 +1761,17 @@ const plugin = definePlugin({
1637
1761
  if (before.enabled !== live.enabled || before.dockerBin !== live.dockerBin || !sameTargets(before.targets, live.targets) || mutationsRevoked) {
1638
1762
  closeAllStreams();
1639
1763
  }
1640
- // 工具注册面只受启用态 / 能力开关影响;dockerBin 不改变工具清单但影响执行,
1764
+ // 工具注册面受启用态 / 能力开关 / **目标列表**影响;dockerBin 不改变工具清单但影响执行,
1641
1765
  // 一起重注册是幂等的,保守带上。forceRefreshTools 给「保存路径」用:上一次注册
1642
1766
  // 可能只成功了一部分,重存同一份配置也要能重试(D107)。
1767
+ // 目标列表必须算进来:零目标时只注册两条空态工具(见 ZERO_TARGET_TOOLS),配好目标后
1768
+ // 不在这里补注册,就得等重启——那正是「配了目标、工具却没有」的那个半吊子状态。
1643
1769
  if (options?.forceRefreshTools === true
1644
1770
  || before.enabled !== live.enabled
1645
1771
  || before.dockerBin !== live.dockerBin
1646
1772
  || before.allowMutations !== live.allowMutations
1647
- || before.allowExec !== live.allowExec) {
1773
+ || before.allowExec !== live.allowExec
1774
+ || !sameTargets(before.targets, live.targets)) {
1648
1775
  refreshTools();
1649
1776
  }
1650
1777
  if (before.enabled !== live.enabled || before.announceToAgent !== live.announceToAgent) {
@@ -1704,17 +1831,27 @@ const plugin = definePlugin({
1704
1831
  return `\n- ${row.name} [${row.state}]${health} image=${row.image}${ports}${compose} id=${row.id}`;
1705
1832
  };
1706
1833
  /** 跨目标容器渲染:按目标分组,失败的目标单独一行说明(部分成功也要可读)。 */
1707
- const renderAggregatedContainers = (groups) => {
1834
+ const renderAggregatedContainers = (groups, args) => {
1708
1835
  if (groups.length === 0)
1709
1836
  return '尚未配置任何 Docker 目标(插件配置 → Docker 容器面板)。';
1710
- const total = groups.reduce((sum, group) => sum + (group.containers?.length ?? 0), 0);
1837
+ // 「共 N 个容器」要说总数而不是本页条数:分页后两者不是一回事,混用会把一页当全部
1838
+ const total = groups.reduce((sum, group) => sum + (group.total ?? group.containers?.length ?? 0), 0);
1711
1839
  const failed = groups.filter((group) => !group.ok);
1712
1840
  const head = `所有目标共 ${String(total)} 个容器(${String(groups.length)} 个目标${failed.length > 0 ? `,${String(failed.length)} 个不可达` : ''}):`;
1713
1841
  return head + groups.map((group) => {
1714
1842
  if (!group.ok)
1715
1843
  return `\n\n■ ${group.target}(${group.label})— 不可用:${group.error ?? '未知错误'}`;
1716
1844
  const rows = group.containers ?? [];
1717
- return `\n\n■ ${group.target}(${group.label})— ${String(rows.length)} 个容器` + rows.map(renderPsRow).join('');
1845
+ /*
1846
+ * 空页的两种成因同样要分开(与单目标的 `emptyListText` 同口径):offset 翻过头时
1847
+ * 若照抄「— 0 个容器」,模型会当成「这台主机上没有容器」——正是 D12/D42 那类假阴性。
1848
+ */
1849
+ if (rows.length === 0 && (group.total ?? 0) > 0) {
1850
+ return `\n\n■ ${group.target}(${group.label})— 本页没有条目(共 ${String(group.total)} 个,offset 已越过末页,改用更小的 offset 重试)`;
1851
+ }
1852
+ // 每组的截断信号单独给(组数不变、组内切页):某台主机还有下一页时要说出来
1853
+ const note = pagingNote(listPage(args, group.total, group.truncated === true), rows.length);
1854
+ return `\n\n■ ${group.target}(${group.label})— ${String(rows.length)} 个容器${note}` + rows.map(renderPsRow).join('');
1718
1855
  }).join('');
1719
1856
  };
1720
1857
  const ATTENTION_LABEL = {
@@ -1759,10 +1896,10 @@ const plugin = definePlugin({
1759
1896
  }).join('');
1760
1897
  }).join('');
1761
1898
  };
1762
- const renderContainers = (target, rows) => {
1899
+ const renderContainers = (target, rows, page) => {
1763
1900
  if (rows.length === 0)
1764
- return `目标 ${target}:没有容器。`;
1765
- return `目标 ${target} 的容器(${String(rows.length)} 个):` + rows.map(renderPsRow).join('');
1901
+ return emptyListText('容器', target, page);
1902
+ return listHead('容器', target, page, rows.length) + rows.map(renderPsRow).join('') + pagingNote(page, rows.length);
1766
1903
  };
1767
1904
  const renderStats = (target, rows) => {
1768
1905
  if (rows.length === 0)
@@ -1795,29 +1932,29 @@ const plugin = definePlugin({
1795
1932
  lines.push(`- 最近健康检查:${detail.healthLogTail}`);
1796
1933
  return lines.join('\n');
1797
1934
  };
1798
- const renderNetworks = (target, rows) => {
1935
+ const renderNetworks = (target, rows, page) => {
1799
1936
  if (rows.length === 0)
1800
- return `目标 ${target}:没有网络。`;
1801
- return `目标 ${target} 的网络(${String(rows.length)} 个):` + rows.map((row) => {
1937
+ return emptyListText('网络', target, page);
1938
+ return listHead('网络', target, page, rows.length) + rows.map((row) => {
1802
1939
  const internal = row.internal === true ? ' internal=true' : '';
1803
1940
  return `\n- ${row.name} driver=${row.driver} scope=${row.scope}${internal} id=${row.id}`;
1804
- }).join('');
1941
+ }).join('') + pagingNote(page, rows.length);
1805
1942
  };
1806
- const renderVolumes = (target, rows) => {
1943
+ const renderVolumes = (target, rows, page) => {
1807
1944
  if (rows.length === 0)
1808
- return `目标 ${target}:没有卷。`;
1809
- return `目标 ${target} 的卷(${String(rows.length)} 个):` + rows.map((row) => {
1945
+ return emptyListText('卷', target, page);
1946
+ return listHead('卷', target, page, rows.length) + rows.map((row) => {
1810
1947
  const mount = row.mountpoint === undefined || row.mountpoint === '' ? '' : ` mount=${row.mountpoint}`;
1811
1948
  return `\n- ${row.name} driver=${row.driver} scope=${row.scope}${mount}`;
1812
- }).join('');
1949
+ }).join('') + pagingNote(page, rows.length);
1813
1950
  };
1814
- const renderImages = (target, rows) => {
1951
+ const renderImages = (target, rows, page) => {
1815
1952
  if (rows.length === 0)
1816
- return `目标 ${target}:没有镜像。`;
1817
- return `目标 ${target} 的镜像(${String(rows.length)} 个):` + rows.map((row) => {
1953
+ return emptyListText('镜像', target, page);
1954
+ return listHead('镜像', target, page, rows.length) + rows.map((row) => {
1818
1955
  const created = row.createdSince === undefined || row.createdSince === '' ? '' : ' (' + row.createdSince + ')';
1819
1956
  return `\n- ${row.reference} ${row.sizeText}${created} id=${row.id}`;
1820
- }).join('');
1957
+ }).join('') + pagingNote(page, rows.length);
1821
1958
  };
1822
1959
  /** 镜像详情 + 构建历史(agent 工具 docker_image_inspect 的可读渲染)。 */
1823
1960
  const renderImageDetail = (payload) => {
@@ -1877,6 +2014,13 @@ const plugin = definePlugin({
1877
2014
  */
1878
2015
  const failures = [];
1879
2016
  const add = (toolName, definition) => {
2017
+ /*
2018
+ * 零目标闸(见 ZERO_TARGET_TOOLS):其余工具没有可寻址的目标,注册了只会让模型
2019
+ * 白试一次;放在 add 里过滤,是为了保住注册顺序、失败汇总与 registeredNames 的
2020
+ * 实况语义(它们仍然是「这次真正注册了什么」的如实记录)。
2021
+ */
2022
+ if (live.targets.length === 0 && !ZERO_TARGET_TOOLS.has(toolName))
2023
+ return;
1880
2024
  try {
1881
2025
  toolDisposers.push(tools.register(definition));
1882
2026
  registeredNames.push(toolName);
@@ -1996,6 +2140,8 @@ const plugin = definePlugin({
1996
2140
  parameters: {
1997
2141
  target: { type: 'string', description: '目标名;传 `*` 或省略(仅一个目标时)表示当前目标/全部目标(docker_targets 列出)' },
1998
2142
  all: { type: 'boolean', description: 'true 时包含已停止容器(默认 false)' },
2143
+ limit: pageParams.limit,
2144
+ offset: pageParams.offset,
1999
2145
  },
2000
2146
  output: {
2001
2147
  schema: {
@@ -2003,6 +2149,8 @@ const plugin = definePlugin({
2003
2149
  additionalProperties: false,
2004
2150
  properties: {
2005
2151
  target: { type: 'string' },
2152
+ total: { type: 'number' },
2153
+ truncated: { type: 'boolean' },
2006
2154
  containers: {
2007
2155
  type: 'array',
2008
2156
  items: {
@@ -2032,6 +2180,9 @@ const plugin = definePlugin({
2032
2180
  label: { type: 'string', required: true },
2033
2181
  ok: { type: 'boolean', required: true },
2034
2182
  error: { type: 'string' },
2183
+ // 组内分页(T1):组数不变、每组各自切,所以 total / truncated 也在组上
2184
+ total: { type: 'number' },
2185
+ truncated: { type: 'boolean' },
2035
2186
  containers: {
2036
2187
  type: 'array',
2037
2188
  items: {
@@ -2056,15 +2207,16 @@ const plugin = definePlugin({
2056
2207
  },
2057
2208
  },
2058
2209
  },
2059
- render: (_args, value) => {
2210
+ render: (args, value) => {
2060
2211
  const v = value;
2061
2212
  if (Array.isArray(v.groups))
2062
- return [{ type: 'text', text: renderAggregatedContainers(v.groups) }];
2063
- return [{ type: 'text', text: renderContainers(v.target ?? '?', v.containers ?? []) }];
2213
+ return [{ type: 'text', text: renderAggregatedContainers(v.groups, args) }];
2214
+ return [{ type: 'text', text: renderContainers(v.target ?? '?', v.containers ?? [], listPage(args, v.total, v.truncated === true)) }];
2064
2215
  },
2065
2216
  },
2066
2217
  async execute(args) {
2067
2218
  const input = (args ?? {});
2219
+ const window = pageWindow(input);
2068
2220
  const isAll = typeof input.target === 'string' && input.target.trim() === '*';
2069
2221
  const toRow = (row) => ({
2070
2222
  // 短 ID(D47):docker_attention 已是 shortId,同一字段跨工具宽度要一致
@@ -2085,14 +2237,23 @@ const plugin = definePlugin({
2085
2237
  if (isAll || (input.target === undefined && targetsNow().length > 1)) {
2086
2238
  const names = targetsFor('*');
2087
2239
  const collected = await aggregateAcrossTargets(names, (targetApi) => targetApi.listContainers(input.all === true));
2240
+ // 跨目标分页在**组内**切(T1):组数与「哪些目标不可达」都保持不变,
2241
+ // 每组带自己的 total / truncated —— 否则一个目标 150 个容器会把别组挤没
2088
2242
  return {
2089
- groups: collected.map((group) => ({
2090
- target: group.target,
2091
- label: group.label,
2092
- ok: group.ok,
2093
- ...(group.error === undefined ? {} : { error: group.error }),
2094
- ...(group.data === undefined ? {} : { containers: group.data.map(toRow) }),
2095
- })),
2243
+ groups: collected.map((group) => {
2244
+ const paged = group.data === undefined ? undefined : paginate(group.data, window);
2245
+ return {
2246
+ target: group.target,
2247
+ label: group.label,
2248
+ ok: group.ok,
2249
+ ...(group.error === undefined ? {} : { error: group.error }),
2250
+ ...(paged === undefined ? {} : {
2251
+ containers: paged.rows.map(toRow),
2252
+ total: paged.total,
2253
+ truncated: paged.truncated,
2254
+ }),
2255
+ };
2256
+ }),
2096
2257
  };
2097
2258
  }
2098
2259
  const picked = pickTarget(input.target);
@@ -2102,7 +2263,8 @@ const plugin = definePlugin({
2102
2263
  if (api === undefined)
2103
2264
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2104
2265
  const containers = await api.listContainers(input.all === true);
2105
- return { target: picked.name, containers: containers.map(toRow) };
2266
+ const paged = paginate(containers, window);
2267
+ return { target: picked.name, containers: paged.rows.map(toRow), total: paged.total, truncated: paged.truncated };
2106
2268
  },
2107
2269
  }));
2108
2270
  add('docker_attention', defineTool({
@@ -2331,7 +2493,15 @@ const plugin = definePlugin({
2331
2493
  },
2332
2494
  render: (_args, value) => {
2333
2495
  const v = value;
2334
- const head = `容器 ${v.id ?? '?'}(目标 ${v.target ?? '?'})日志${v.truncated === true ? '(输出已截断)' : ''}:\n\n`;
2496
+ /*
2497
+ * 截断时要给出**下一步**(D105 的判据):只说「已截断」等于告诉模型「没有别的办法」。
2498
+ * 三种可执行做法按代价排序:更小的 tail(最省)→ since/until 圈窗 → 换用
2499
+ * docker_logs_grep 到深处检索(它扫全历史、只有返回侧才截)。
2500
+ */
2501
+ const cut = v.truncated === true
2502
+ ? '(输出已截断,已丢弃最旧的行:可传更小的 `tail`、加 `since`/`until` 圈时间窗,或改用 `docker_logs_grep` 带 pattern 检索深处)'
2503
+ : '';
2504
+ const head = `容器 ${v.id ?? '?'}(目标 ${v.target ?? '?'})日志${cut}:\n\n`;
2335
2505
  return [{ type: 'text', text: head + (v.text === undefined || v.text === '' ? '(无日志)' : v.text) }];
2336
2506
  },
2337
2507
  },
@@ -2362,7 +2532,9 @@ const plugin = definePlugin({
2362
2532
  name: 'docker_logs_grep',
2363
2533
  // 只读工具:与同轮其它工具并发执行(同 docker_logs)
2364
2534
  isConcurrencySafe: () => true,
2365
- description: '在容器日志里做**服务端检索**:扫全部历史(可用 since/until 圈时间窗),返回命中行及其前后 context 行。与 docker_logs 的分工:报错就在最近尾部 → docker_logs(更便宜);埋在大量日志深处 / 找关键词、异常码、请求 ID / 特定时间段 → 本工具。默认字面子串匹配(grep -F 语义,零 ReDoS);regexp=true 才按 JS 正则解释。返回如实报告 matched(总命中数,即使没返回)/ scanned(扫描行数)/ limited / truncated——「只扫到这些」与「扫完了没更多」是可区分的。',
2535
+ // 返回侧行上限只认 src/docker.ts 的 MAX_RESULT_LINES:描述里用插值,别再写字面量
2536
+ // (这里曾把上限写死在描述里,改常量时描述不跟着变;守卫见 test/result-limit-single-source.test.ts)
2537
+ description: `在容器日志里做**服务端检索**:扫全部历史(可用 since/until 圈时间窗),返回命中行及其前后 context 行。与 docker_logs 的分工:报错就在最近尾部 → docker_logs(更便宜);埋在大量日志深处 / 找关键词、异常码、请求 ID / 特定时间段 → 本工具。默认字面子串匹配(grep -F 语义,零 ReDoS);regexp=true 才按 JS 正则解释。返回如实报告 matched(总命中数,即使没返回)/ scanned(扫描行数)/ limited / truncated——「只扫到这些」与「扫完了没更多」是可区分的。返回行数另有上限(${String(MAX_RESULT_LINES)}):超了截到上限并给 truncatedForSize 与 notes(缩小 limit / 减小 context / 加 since·until),matched 仍报全量。`,
2366
2538
  parameters: {
2367
2539
  target: targetParam,
2368
2540
  id: { type: 'string', required: true, description: '容器名或 ID' },
@@ -2388,6 +2560,10 @@ const plugin = definePlugin({
2388
2560
  returned: { type: 'number', required: true },
2389
2561
  limited: { type: 'boolean' },
2390
2562
  truncated: { type: 'boolean' },
2563
+ // 返回侧行闸的两位:与后端 LogGrepResult 同步声明(additionalProperties:false,
2564
+ // 漏声明会被宿主校验挡掉整条结果)——见 src/docker.ts 的 MAX_RESULT_LINES。
2565
+ truncatedForSize: { type: 'boolean' },
2566
+ notes: { type: 'array', items: { type: 'string' } },
2391
2567
  contextLines: { type: 'number', required: true },
2392
2568
  lines: {
2393
2569
  type: 'array',
@@ -2413,6 +2589,9 @@ const plugin = definePlugin({
2413
2589
  notes.push(`命中超过上限,只返回前几组窗口(总命中 ${String(v.matched ?? 0)},可加大 limit 或缩小时间窗)`);
2414
2590
  if (v.truncated === true)
2415
2591
  notes.push('扫描输出超过字节上限,最旧部分未参与检索(窗口偏新;请缩小 since/until)');
2592
+ // 返回侧行闸:收窄建议由后端(notes)给出,这里只负责展示,免得两处各写一份
2593
+ if (v.truncatedForSize === true)
2594
+ notes.push(...(v.notes ?? ['返回行数超过上限,已截断']));
2416
2595
  const head = `容器 ${v.id ?? '?'}(目标 ${v.target ?? '?'})日志检索:扫描 ${String(v.scanned ?? 0)} 行,命中 ${String(v.matched ?? 0)} 行,返回 ${String(v.returned ?? 0)} 行(含 ±${String(v.contextLines ?? 0)} 行上下文)${notes.length > 0 ? `——${notes.join(';')}` : ''}:\n\n`;
2417
2596
  const body = (v.lines ?? []).map((line) => `${String(line.seq ?? '?').padStart(6, ' ')} ${line.hit === true ? '>' : ' '} [${line.stream ?? 'out'}] ${line.text ?? ''}`).join('\n');
2418
2597
  return [{ type: 'text', text: head + (body === '' ? '(无命中)' : body) }];
@@ -2450,6 +2629,8 @@ const plugin = definePlugin({
2450
2629
  returned: result.returned,
2451
2630
  ...(result.limited ? { limited: true } : {}),
2452
2631
  ...(result.truncated ? { truncated: true } : {}),
2632
+ ...(result.truncatedForSize ? { truncatedForSize: true } : {}),
2633
+ ...(result.notes === undefined ? {} : { notes: result.notes }),
2453
2634
  contextLines: result.contextLines,
2454
2635
  lines: result.lines.map((line) => ({
2455
2636
  seq: line.seq,
@@ -2603,13 +2784,21 @@ const plugin = definePlugin({
2603
2784
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
2604
2785
  isConcurrencySafe: () => true,
2605
2786
  description: '列出某个目标上的镜像(仓库:标签、大小、创建时间、短 ID)。',
2606
- parameters: { target: targetParam },
2787
+ parameters: {
2788
+ target: targetParam,
2789
+ limit: pageParams.limit,
2790
+ offset: pageParams.offset,
2791
+ },
2607
2792
  output: {
2608
2793
  schema: {
2609
2794
  type: 'object',
2610
2795
  additionalProperties: false,
2611
2796
  properties: {
2612
2797
  target: { type: 'string', required: true },
2798
+ // total / truncated 必须在这里声明(additionalProperties: false):
2799
+ // 返回值里有、schema 里没有,宿主校验会挡掉整个结果(ToolOutputError)
2800
+ total: { type: 'number' },
2801
+ truncated: { type: 'boolean' },
2613
2802
  images: {
2614
2803
  type: 'array',
2615
2804
  required: true,
@@ -2626,9 +2815,9 @@ const plugin = definePlugin({
2626
2815
  },
2627
2816
  },
2628
2817
  },
2629
- render: (_args, value) => {
2818
+ render: (args, value) => {
2630
2819
  const v = value;
2631
- return [{ type: 'text', text: renderImages(v.target ?? '?', v.images ?? []) }];
2820
+ return [{ type: 'text', text: renderImages(v.target ?? '?', v.images ?? [], listPage(args, v.total, v.truncated === true)) }];
2632
2821
  },
2633
2822
  },
2634
2823
  async execute(args) {
@@ -2640,14 +2829,18 @@ const plugin = definePlugin({
2640
2829
  if (api === undefined)
2641
2830
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2642
2831
  const images = await api.images();
2832
+ // 全量拿到之后才切(T1):后端 `images()` 的返回契约不变(面板要完整列表)
2833
+ const paged = paginate(images, pageWindow(input));
2643
2834
  return {
2644
2835
  target: picked.name,
2645
- images: images.map((row) => ({
2836
+ images: paged.rows.map((row) => ({
2646
2837
  reference: row.reference,
2647
2838
  sizeText: row.sizeText,
2648
2839
  ...(row.createdSince === '' ? {} : { createdSince: row.createdSince }),
2649
2840
  id: row.shortId,
2650
2841
  })),
2842
+ total: paged.total,
2843
+ truncated: paged.truncated,
2651
2844
  };
2652
2845
  },
2653
2846
  }));
@@ -2694,13 +2887,20 @@ const plugin = definePlugin({
2694
2887
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
2695
2888
  isConcurrencySafe: () => true,
2696
2889
  description: '列出某个目标上的 docker 网络(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表要进详情页看,不在列表里逐条 inspect。',
2697
- parameters: { target: targetParam },
2890
+ parameters: {
2891
+ target: targetParam,
2892
+ limit: pageParams.limit,
2893
+ offset: pageParams.offset,
2894
+ },
2698
2895
  output: {
2699
2896
  schema: {
2700
2897
  type: 'object',
2701
2898
  additionalProperties: false,
2702
2899
  properties: {
2703
2900
  target: { type: 'string', required: true },
2901
+ // 同 docker_images:additionalProperties: false 下必须声明,否则整个结果被挡
2902
+ total: { type: 'number' },
2903
+ truncated: { type: 'boolean' },
2704
2904
  networks: {
2705
2905
  type: 'array',
2706
2906
  required: true,
@@ -2718,9 +2918,9 @@ const plugin = definePlugin({
2718
2918
  },
2719
2919
  },
2720
2920
  },
2721
- render: (_args, value) => {
2921
+ render: (args, value) => {
2722
2922
  const v = value;
2723
- return [{ type: 'text', text: renderNetworks(v.target ?? '?', v.networks ?? []) }];
2923
+ return [{ type: 'text', text: renderNetworks(v.target ?? '?', v.networks ?? [], listPage(args, v.total, v.truncated === true)) }];
2724
2924
  },
2725
2925
  },
2726
2926
  async execute(args) {
@@ -2732,15 +2932,18 @@ const plugin = definePlugin({
2732
2932
  if (api === undefined)
2733
2933
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2734
2934
  const rows = await api.networks();
2935
+ const paged = paginate(rows, pageWindow(input));
2735
2936
  return {
2736
2937
  target: picked.name,
2737
- networks: rows.map((row) => ({
2938
+ networks: paged.rows.map((row) => ({
2738
2939
  name: row.name,
2739
2940
  driver: row.driver,
2740
2941
  scope: row.scope,
2741
2942
  ...(row.internal ? { internal: true } : {}),
2742
2943
  id: row.shortId,
2743
2944
  })),
2945
+ total: paged.total,
2946
+ truncated: paged.truncated,
2744
2947
  };
2745
2948
  },
2746
2949
  }));
@@ -2749,13 +2952,20 @@ const plugin = definePlugin({
2749
2952
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
2750
2953
  isConcurrencySafe: () => true,
2751
2954
  description: '列出某个目标上的 docker 卷(名称 / 驱动 / 范围 / 挂载点)。',
2752
- parameters: { target: targetParam },
2955
+ parameters: {
2956
+ target: targetParam,
2957
+ limit: pageParams.limit,
2958
+ offset: pageParams.offset,
2959
+ },
2753
2960
  output: {
2754
2961
  schema: {
2755
2962
  type: 'object',
2756
2963
  additionalProperties: false,
2757
2964
  properties: {
2758
2965
  target: { type: 'string', required: true },
2966
+ // 同 docker_images:additionalProperties: false 下必须声明,否则整个结果被挡
2967
+ total: { type: 'number' },
2968
+ truncated: { type: 'boolean' },
2759
2969
  volumes: {
2760
2970
  type: 'array',
2761
2971
  required: true,
@@ -2772,9 +2982,9 @@ const plugin = definePlugin({
2772
2982
  },
2773
2983
  },
2774
2984
  },
2775
- render: (_args, value) => {
2985
+ render: (args, value) => {
2776
2986
  const v = value;
2777
- return [{ type: 'text', text: renderVolumes(v.target ?? '?', v.volumes ?? []) }];
2987
+ return [{ type: 'text', text: renderVolumes(v.target ?? '?', v.volumes ?? [], listPage(args, v.total, v.truncated === true)) }];
2778
2988
  },
2779
2989
  },
2780
2990
  async execute(args) {
@@ -2786,14 +2996,17 @@ const plugin = definePlugin({
2786
2996
  if (api === undefined)
2787
2997
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2788
2998
  const rows = await api.volumes();
2999
+ const paged = paginate(rows, pageWindow(input));
2789
3000
  return {
2790
3001
  target: picked.name,
2791
- volumes: rows.map((row) => ({
3002
+ volumes: paged.rows.map((row) => ({
2792
3003
  name: row.name,
2793
3004
  driver: row.driver,
2794
3005
  scope: row.scope,
2795
3006
  ...(row.mountpoint === '' ? {} : { mountpoint: row.mountpoint }),
2796
3007
  })),
3008
+ total: paged.total,
3009
+ truncated: paged.truncated,
2797
3010
  };
2798
3011
  },
2799
3012
  }));
@@ -3063,8 +3276,36 @@ const plugin = definePlugin({
3063
3276
  toolsApi = toolsCtx.tools;
3064
3277
  refreshTools();
3065
3278
  console.log('[dsh-docker] agent tools ' + (live.enabled ? 'registered (' + registeredNames.join(', ') + ')' : 'skipped (disabled)'));
3279
+ /*
3280
+ * 按会话档位在 **agent 作用域**上裁掉用不到的 write / exec 工具(与 tty 试点同一份实现,
3281
+ * 2026-10 上收到 kit 的 tier-restrict.ts)。
3282
+ *
3283
+ * 位置是这一层的**关键**:刻意放在 effect 体里、**不放进 refreshTools**。能力开关变化会走
3284
+ * refreshTools 重注册工具(先撤后挂),而重注册不该把已有 agent 的裁剪一并撤掉——
3285
+ * `agent/created` 不会为老 agent 再发一次,撤了就再也裁不回来。与工具同生命周期的是
3286
+ * **这一层订阅**,随插件卸载 / tools 服务撤离一起撤。
3287
+ *
3288
+ * 传 `toolsCtx` 而不是插件 `ctx`:本插件的 tools 是**动态 inject** 拿到的(插件对象没有
3289
+ * 声明式 `inject: ['tools']`),在插件 ctx 上读 `tools` 会撞 cordis 的 inject 守卫——而
3290
+ * `attachTierRestrict` 要用 tools 的全局 `schemas()` 现算 deny 名单,拿不到就**静默退化
3291
+ * 成不裁剪**(不报错,比抛错更难发现)。`toolsCtx` 是这条 inject 回调里绑定了 tools 的
3292
+ * 上下文,正好是它需要的取法。
3293
+ *
3294
+ * 实测(cordis 4.0.5:服务由**兄弟 fiber** 提供,与真宿主的 dsh-tools 同形):
3295
+ * - 无声明式 inject 的插件 ctx:`ctx.tools` 抛 `cannot get property "tools" without
3296
+ * inject`,`ctx.get('tools')` 是 `undefined`;
3297
+ * - 同一条 `ctx.inject(['tools'], cb)` 的 `cb` 里:`toolsCtx.tools` 正常,
3298
+ * `toolsCtx.on('agent/created')` 收得到事件(本层两个依赖都在这里成立)。
3299
+ */
3300
+ const disposeTierRestrict = attachTierRestrict(toolsCtx, {
3301
+ pkg: 'docker',
3302
+ prefixes: DOCKER_TIER_PREFIXES,
3303
+ classify: (tool) => DOCKER_TIER_CLASS[tool],
3304
+ });
3066
3305
  return () => {
3067
3306
  toolsApi = undefined;
3307
+ // 先撤裁剪、再撤工具:撤销走的是 agent scope 上的 disposer,工具还挂着时撤最干净。
3308
+ disposeTierRestrict();
3068
3309
  for (const dispose of toolDisposers) {
3069
3310
  try {
3070
3311
  dispose();
@@ -3103,12 +3344,19 @@ const plugin = definePlugin({
3103
3344
  /**
3104
3345
  * GET /logs/stream — 容器日志实时流(`docker logs --follow` → SSE)。
3105
3346
  *
3106
- * 参数:`tail`(0~5000,缺省取配置;**0 = 不补历史只跟随**,客户端断线重连用,
3107
- * 见 D133)、`timestamps`、`since`。
3347
+ * 参数:`id`(单容器,老参数)或 `ids=a,b,c`(多容器,一条连接带多个源;聚合视图 /
3348
+ * Compose 项目日志走这条),`tail`(0~5000,缺省取配置;**0 = 不补历史只跟随**,
3349
+ * 客户端断线重连用,见 D133)、`timestamps`、`since`。
3108
3350
  *
3109
3351
  * 事件协议(每帧 `event:` + 单行 JSON `data:`):
3352
+ * - `ready` `{}` 执行管道真的起来了(多源时只有第一个源起来的那一次,见 readyOnStart)
3110
3353
  * - `line` `{"d":"..."}` stdout 分片 / `{"e":"..."}` stderr 分片
3111
- * - `end` `{"reason":"container-exit","code":N}` 容器停止、docker logs -f 自然退出
3354
+ * - `line` `{"id":"<容器 id>","d"|"e":"..."}` —— **仅多源**:这段字节属于哪个容器
3355
+ * - `source-end` `{"id":"<id>","code":<N|null>}` 该容器的 logs -f 自然退出(仅多源)
3356
+ * - `source-error` `{"id":"<id>","message":"..."}` 该容器**这一条子流**失败(仅多源,
3357
+ * spawn 错 / 远端通道被拒):其余源继续跟着,整条连接不关
3358
+ * - `end` `{"reason":"container-exit","code":N}` 全部源结束(多源时 code 取最后一个
3359
+ * 非 null 的退出码,都 null 就是 null)
3112
3360
  * - `end` `{"reason":"output-limit"}` 宿主侧背压队列溢出(客户端消费跟不上,D133)
3113
3361
  * - `error` `{"message":"..."}` 后关闭(参数 / 执行失败)
3114
3362
  * 心跳:每 15s 一帧 `: ping` 注释;客户端断开则静默中止执行器(SIGTERM 阶梯 /
@@ -3121,17 +3369,49 @@ const plugin = definePlugin({
3121
3369
  writeJson(res, 400, { error: picked.error ?? '无效的 target' });
3122
3370
  return;
3123
3371
  }
3372
+ /*
3373
+ * 参数归一:`ids` 非空就按多源走,`id` 是单源的老参数。
3374
+ *
3375
+ * 为什么 `ids` 优先:它只会来自**按 ≥3 协议协商过的**新客户端,那里一条连接就代表
3376
+ * 整个聚合视图;而 `id` 是老客户端一次一个容器的路径。两者同时出现没有合理场景
3377
+ * (客户端两处代码各拼各的参数),按更强的那个解释比报 400 更宽容。
3378
+ */
3379
+ const rawIds = params.get('ids');
3380
+ const idsParam = rawIds === null ? [] : rawIds.split(',').map((id) => id.trim()).filter((id) => id !== '');
3381
+ const multiSource = idsParam.length > 0;
3124
3382
  const idParam = params.get('id');
3125
- if (idParam === null || idParam.trim() === '') {
3126
- writeJson(res, 400, { error: 'id 必填' });
3127
- return;
3383
+ let safeIds = [];
3384
+ let safeId = '';
3385
+ if (multiSource) {
3386
+ /*
3387
+ * 先判数量、再逐条校验:两者同时不成立时(33 条里还夹着非法条目),先报出的
3388
+ * 原因不该取决于那条非法条目排在第几位——数量是一次比较,天然稳定。
3389
+ */
3390
+ if (idsParam.length > MAX_LOGS_SOURCES) {
3391
+ writeJson(res, 400, { error: `ids 最多 ${String(MAX_LOGS_SOURCES)} 个容器(收到 ${String(idsParam.length)} 个)` });
3392
+ return;
3393
+ }
3394
+ try {
3395
+ // 逐条过同一道白名单:多源只是把「一个 id」重复 N 次,校验口径一个字都不能松
3396
+ safeIds = idsParam.map((id) => assertRef(id, 'container'));
3397
+ }
3398
+ catch (error) {
3399
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
3400
+ return;
3401
+ }
3128
3402
  }
3129
- let safeId;
3130
- try {
3131
- safeId = assertRef(idParam, 'container');
3403
+ else if (idParam !== null && idParam.trim() !== '') {
3404
+ try {
3405
+ safeId = assertRef(idParam, 'container');
3406
+ }
3407
+ catch (error) {
3408
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
3409
+ return;
3410
+ }
3132
3411
  }
3133
- catch (error) {
3134
- writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
3412
+ else {
3413
+ // `ids=` 全空(像 `ids=,,`)与「一个参数都没给」是同一件事:没有源可跟
3414
+ writeJson(res, 400, { error: 'id 必填' });
3135
3415
  return;
3136
3416
  }
3137
3417
  const built = apiFor(picked.name);
@@ -3160,42 +3440,124 @@ const plugin = definePlugin({
3160
3440
  return;
3161
3441
  }
3162
3442
  }
3443
+ // 单源 / 多源共用同一份取值规则(非法/越界交给 DockerApi 内的夹紧),抽出来
3444
+ // 只为了不让两个分支各写一遍再各自漂掉
3445
+ const logOptions = {
3446
+ tail: Number.isInteger(tail) ? tail : live.logTailDefault,
3447
+ timestamps: timestampsParam === '1' || timestampsParam === 'true',
3448
+ ...(since !== undefined ? { since } : {}),
3449
+ };
3163
3450
  await openSseStream(res, {
3164
3451
  reason: 'container-exit',
3165
3452
  // 文本尾部流:积压时丢最旧的帧继续跟随,不掐流(D153)
3166
3453
  overflow: 'drop',
3167
3454
  run: async (sendEvent, signal) => {
3168
3455
  /*
3169
- * 事件协议与快照 /logs 完全一致,只多一个 end.reason——**只多一层合帧**
3170
- * (D151):docker 的分片边界与「一行日志」无关,逐 chunk 一帧时话痨容器
3171
- * 每秒能推几千个事件,客户端的每事件固定开销(SSE 派发 + JSON.parse +
3172
- * 拼残行)全砸在主线程上。合帧后客户端收到的仍是同一串字节,只是按窗口
3173
- * 合并成了更少的帧。
3456
+ * 单源老路径:帧仍是 `{d}` / `{e}`,不发 source-end / source-error。
3457
+ * 这条分支必须**逐字节不变**——老客户端(streamProtocol < 3)仍在用,
3458
+ * 多出的帧名它不认识,多出的字段则会破坏它对 `d` / `e` 的解析。
3174
3459
  */
3175
- const coalescer = createSseCoalescer({
3176
- emit: (channel, text) => sendEvent('line', channel === 'd' ? { d: text } : { e: text }),
3177
- });
3178
- try {
3179
- const result = await api.logsStream(safeId, {
3180
- // 与 POST /logs 同一条取值规则:非法/越界交给 DockerApi 内的夹紧
3181
- tail: Number.isInteger(tail) ? tail : live.logTailDefault,
3182
- timestamps: timestampsParam === '1' || timestampsParam === 'true',
3183
- ...(since !== undefined ? { since } : {}),
3184
- }, {
3185
- onStdout: (chunk) => coalescer.push('d', chunk),
3186
- onStderr: (chunk) => coalescer.push('e', chunk),
3187
- onStart: readyOnStart(sendEvent),
3188
- }, signal);
3189
- return result.code;
3190
- }
3191
- finally {
3460
+ if (!multiSource) {
3192
3461
  /*
3193
- * 收尾前把窗口里剩下的行推出去:`end` 帧必须排在它们之后——客户端按到达
3194
- * 序落行,先收到 end 会当场切回快照,窗口里那批行就再也显示不出来了。
3462
+ * 事件协议与快照 /logs 完全一致,只多一个 end.reason——**只多一层合帧**
3463
+ * (D151):docker 的分片边界与「一行日志」无关,逐 chunk 一帧时话痨容器
3464
+ * 每秒能推几千个事件,客户端的每事件固定开销(SSE 派发 + JSON.parse +
3465
+ * 拼残行)全砸在主线程上。合帧后客户端收到的仍是同一串字节,只是按窗口
3466
+ * 合并成了更少的帧。
3195
3467
  */
3196
- coalescer.flush();
3197
- coalescer.dispose();
3468
+ const coalescer = createSseCoalescer({
3469
+ emit: (channel, text) => sendEvent('line', channel === 'd' ? { d: text } : { e: text }),
3470
+ });
3471
+ try {
3472
+ const result = await api.logsStream(safeId, logOptions, {
3473
+ onStdout: (chunk) => coalescer.push('d', chunk),
3474
+ onStderr: (chunk) => coalescer.push('e', chunk),
3475
+ onStart: readyOnStart(sendEvent),
3476
+ }, signal);
3477
+ return result.code;
3478
+ }
3479
+ finally {
3480
+ /*
3481
+ * 收尾前把窗口里剩下的行推出去:`end` 帧必须排在它们之后——客户端按到达
3482
+ * 序落行,先收到 end 会当场切回快照,窗口里那批行就再也显示不出来了。
3483
+ */
3484
+ coalescer.flush();
3485
+ coalescer.dispose();
3486
+ }
3198
3487
  }
3488
+ /*
3489
+ * 多源:一条 SSE 带多个容器。
3490
+ *
3491
+ * 为什么不是「每个容器一条 EventSource」:宿主是 HTTP/1.1,浏览器对同一源只有
3492
+ * 6 条连接,而 EventSource 每条独占且**永不释放**——聚合视图一开,4 个服务就吃掉
3493
+ * 大半配额,其余请求开始排队(D165 / README「已知限制」)。一条连接带多源把占用
3494
+ * 压到 1 条,帧里补 `id` 让客户端把分片归位。别顺手把统计 / 事件流也改成这样:
3495
+ * 它们的分帧与去重都按「一条流一个源」写死了,是另一摊事。
3496
+ *
3497
+ * 两个必须守住的语义:
3498
+ * - **单个源失败不许拖垮整条连接**:一个容器 spawn 不起来(或 SSH 配额已满,
3499
+ * MAX_STREAMS_PER_TARGET 是 8)时,其余容器的日志必须照常跟着——否则一个
3500
+ * 坏容器就把整个聚合视图变成空白,还得用户手动重连才能看别的容器。所以每个
3501
+ * 源各自 try/catch,失败落一帧 `source-error`,`run` 整体永远正常 resolve。
3502
+ * - **客户端断开只有一个 signal**(openSseStream 的 controller):必须传给每一个
3503
+ * 源,否则用户关掉面板后没人认领的 `docker logs -f` 子进程 / SSH channel 会
3504
+ * 一直跑下去。
3505
+ */
3506
+ const sendReady = readyOnStart(sendEvent);
3507
+ let readySent = false;
3508
+ /** ready 只发一帧:第一个源起来已足以证明「这条连接真有管道在跑」,每个源各发一遍是噪音。 */
3509
+ const sendReadyOnce = () => {
3510
+ if (readySent)
3511
+ return;
3512
+ readySent = true;
3513
+ sendReady();
3514
+ };
3515
+ // 按 ids 顺序记退出码(失败的源保持 null):end 帧的 code 要从这里挑最后一个非 null
3516
+ const codes = safeIds.map(() => null);
3517
+ await Promise.all(safeIds.map(async (containerId, index) => {
3518
+ // 每个源一个合帧器:窗口是「这个容器」的,混用会让一个话痨容器把别人的行拖到
3519
+ // 它自己的窗口边界上(帧里虽然带 id,但同窗口内两源的字节顺序将不再是各自的到达序)
3520
+ const coalescer = createSseCoalescer({
3521
+ emit: (channel, text) => sendEvent('line', channel === 'd' ? { id: containerId, d: text } : { id: containerId, e: text }),
3522
+ });
3523
+ try {
3524
+ const result = await api.logsStream(containerId, logOptions, {
3525
+ onStdout: (chunk) => coalescer.push('d', chunk),
3526
+ onStderr: (chunk) => coalescer.push('e', chunk),
3527
+ onStart: sendReadyOnce,
3528
+ }, signal);
3529
+ /*
3530
+ * 该源的收尾顺序:先 flush 再发 source-end。
3531
+ *
3532
+ * flush 是**同步**的(emit 直接写帧),所以「窗口里最后一批 line」一定排在
3533
+ * 这一帧 source-end 之前——反过来客户端会提前把这个容器判成已结束,最后
3534
+ * 那批行要么丢归属、要么被当成下一条流的行。
3535
+ */
3536
+ coalescer.flush();
3537
+ sendEvent('source-end', { id: containerId, code: result.code });
3538
+ codes[index] = result.code;
3539
+ }
3540
+ catch (error) {
3541
+ coalescer.flush();
3542
+ sendEvent('source-error', { id: containerId, message: error instanceof Error ? error.message : String(error) });
3543
+ }
3544
+ finally {
3545
+ coalescer.dispose();
3546
+ }
3547
+ }));
3548
+ /*
3549
+ * 全部源都结束了(正常退出或已各自报错)→ 正常返回,由 openSseStream 发既有的
3550
+ * `end{reason:'container-exit', code}`。
3551
+ *
3552
+ * code 的口径:**按 ids 顺序最后一个非 null 的退出码**,都 null 就是 null。多源时
3553
+ * 「整条连接的退出码」只有摘要意义(每个容器的真实退出码在各自的 source-end 里),
3554
+ * 取最后一个是有确定性的那一种,避免让「谁先退出」这种并发时序决定帧内容。
3555
+ */
3556
+ let finalCode = null;
3557
+ for (const code of codes)
3558
+ if (code !== null)
3559
+ finalCode = code;
3560
+ return finalCode;
3199
3561
  },
3200
3562
  });
3201
3563
  };