@hyzyn/dsh-docker 0.11.0 → 0.13.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
@@ -23,6 +23,7 @@ export const DOCKER_TIER_CLASS = {
23
23
  docker_attention: 'read',
24
24
  docker_inspect: 'read',
25
25
  docker_logs: 'read',
26
+ docker_logs_grep: 'read',
26
27
  docker_stats: 'read',
27
28
  docker_events: 'read',
28
29
  docker_images: 'read',
@@ -182,7 +183,7 @@ function isTargetFailure(error) {
182
183
  * 安全性质**(2026-10-07 用户正是照着旧措辞问「这算是漏洞吗」)。守卫:
183
184
  * `scripts/test/exec-terminal-scope.test.ts`(含「退回旧措辞必须变红」的反例)。
184
185
  */
185
- 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(日志快照)、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 权限,不要在用户未明确要求时执行变更操作。';
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 权限,不要在用户未明确要求时执行变更操作。';
186
187
  /**
187
188
  * SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
188
189
  * 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
@@ -1289,6 +1290,18 @@ const plugin = definePlugin({
1289
1290
  ttyBookHosts: [...books.entries()].map(([name, spec]) => ({ name, host: spec.host, port: spec.port })),
1290
1291
  ttyAvailable: books.size > 0,
1291
1292
  toolsRegistered: registeredNames,
1293
+ /*
1294
+ * SSE 帧协议版本(客户端据此决定「怎样才算已就绪」)。
1295
+ *
1296
+ * 2 = 四条流在**执行管道真的起来**时先发一帧 `ready`(见 readyOnStart)。客户端半体
1297
+ * 与宿主半体同包同版本,正常不会错位;但开发档里「宿主进程还是旧代码(模块已在内存
1298
+ * 里)、浏览器却从磁盘读到新的 client.js」这种组合会让新客户端死等一帧永远不来的
1299
+ * `ready`,把界面钉在「正在连接事件流…」上(2026-10-11 用户在 test profile 上实测到)。
1300
+ * 所以把版本放在这里:客户端读到 <2 就退回旧判据(响应头就算连上),读到 2 才只认
1301
+ * `ready`。字段缺失(更老的宿主)由客户端按「发 ready」以外的规则兜底,见
1302
+ * client-src/index.js 的 hostSendsReadyFrame。
1303
+ */
1304
+ streamProtocol: 2,
1292
1305
  };
1293
1306
  };
1294
1307
  /* ---------- 活跃 SSE 长流清点 ---------- */
@@ -1311,6 +1324,16 @@ const plugin = definePlugin({
1311
1324
  activeStreams.clear();
1312
1325
  };
1313
1326
  /* ---------- 通用 SSE 长连接(三条流共用一份基建) ---------- */
1327
+ /**
1328
+ * 「执行管道真的起来了」→ 一帧 `ready`(配合 {@link StreamHandlers.onStart})。
1329
+ *
1330
+ * 为什么必须有这一帧:SSE 的 `onopen` **只证明响应头到了**——`openSseStream` 是先把
1331
+ * 200 + `text/event-stream` flush 出去、再去起 `docker ...`。目标不可达 / SSH 正在
1332
+ * 重连时 `acquire()` 可能卡十几秒,浏览器却已经「连上」了;客户端据此把活动条点亮成
1333
+ * 绿色的「实时接收中(docker events)」,而那条流一个字都还没收到(2026-10-11 用户现场)。
1334
+ * 现在客户端的 `open` 只认这帧,响应头不再算数。
1335
+ */
1336
+ const readyOnStart = (sendEvent) => () => sendEvent('ready', {});
1314
1337
  /**
1315
1338
  * 通用 SSE 长连接。日志流(`docker logs -f`)、统计流(`docker stats`)、
1316
1339
  * 拉取流(`docker pull`)三者只差「执行器 + 结束原因 + end 附加字段」,
@@ -1325,6 +1348,7 @@ const plugin = definePlugin({
1325
1348
  * - 执行器抛错 → `error` 帧后关闭响应。
1326
1349
  *
1327
1350
  * 调用方负责「参数校验」——校验失败时写常规 JSON(4xx)并直接返回,不建流。
1351
+ * 管道起来后要发的那帧 `ready` 见 {@link readyOnStart}(由调用方挂在 `onStart` 上)。
1328
1352
  */
1329
1353
  const openSseStream = async (res, options) => {
1330
1354
  const write = res.write?.bind(res);
@@ -2265,13 +2289,14 @@ const plugin = definePlugin({
2265
2289
  name: 'docker_logs',
2266
2290
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
2267
2291
  isConcurrencySafe: () => true,
2268
- description: '读取某个容器的日志尾部(docker logs --tail)。默认行数取插件配置 logTailDefault(出厂 200)、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
2292
+ description: '读取某个容器的日志尾部(docker logs --tail)。默认行数取插件配置 logTailDefault(出厂 200)、不带时间戳;可加 timestamps / since / until。日志可能很大,优先用 tail 而不是全量。报错埋在更早的深处、或要看特定时间窗时改用 docker_logs_grep(服务端检索)。',
2269
2293
  parameters: {
2270
2294
  target: targetParam,
2271
2295
  id: { type: 'string', required: true, description: '容器名或 ID' },
2272
2296
  tail: { type: 'number', description: '尾部行数(1~5000,默认取配置 logTailDefault;越界值会被静默夹紧到边界)' },
2273
2297
  timestamps: { type: 'boolean', description: 'true 时每行带时间戳' },
2274
2298
  since: { type: 'string', description: '起始时间(docker --since 语法,如 10m、2026-09-09T10:00:00)' },
2299
+ until: { type: 'string', description: '结束时间(docker --until 语法,与 since 同一口径,如 2026-09-09T10:05:00;配 since 圈定时间窗)' },
2275
2300
  },
2276
2301
  output: {
2277
2302
  schema: {
@@ -2307,10 +2332,115 @@ const plugin = definePlugin({
2307
2332
  // 完全不校验,docker 的参数错误会变成一句不可读的报错。空/纯空白按「未传」处理(D98):
2308
2333
  // 否则模型传个空串会拿到「since 必填」这种与事实相反的提示。
2309
2334
  ...(typeof input.since === 'string' && input.since.trim() !== '' ? { since: assertSince(input.since) } : {}),
2335
+ // until 与 since 同一口径(docker --until 语法一致),复用同一个校验函数
2336
+ ...(typeof input.until === 'string' && input.until.trim() !== '' ? { until: assertSince(input.until, 'until') } : {}),
2310
2337
  });
2311
2338
  return { target: picked.name, id: result.id, text: result.text, truncated: result.truncated };
2312
2339
  },
2313
2340
  }));
2341
+ add('docker_logs_grep', defineTool({
2342
+ name: 'docker_logs_grep',
2343
+ // 只读工具:与同轮其它工具并发执行(同 docker_logs)
2344
+ isConcurrencySafe: () => true,
2345
+ description: '在容器日志里做**服务端检索**:扫全部历史(可用 since/until 圈时间窗),返回命中行及其前后 context 行。与 docker_logs 的分工:报错就在最近尾部 → docker_logs(更便宜);埋在大量日志深处 / 找关键词、异常码、请求 ID / 特定时间段 → 本工具。默认字面子串匹配(grep -F 语义,零 ReDoS);regexp=true 才按 JS 正则解释。返回如实报告 matched(总命中数,即使没返回)/ scanned(扫描行数)/ limited / truncated——「只扫到这些」与「扫完了没更多」是可区分的。',
2346
+ parameters: {
2347
+ target: targetParam,
2348
+ id: { type: 'string', required: true, description: '容器名或 ID' },
2349
+ pattern: { type: 'string', required: true, description: '要搜索的字面子串(默认),或 regexp=true 时的 JS 正则源文;≤512 字符' },
2350
+ regexp: { type: 'boolean', description: 'true = 把 pattern 当 JS 正则;默认 false = 字面子串。正则请避免嵌套量词(回溯风险)' },
2351
+ ignoreCase: { type: 'boolean', description: 'true = 忽略大小写(字面模式等价 grep -i,正则模式加 i 标志)' },
2352
+ since: { type: 'string', description: '起始时间(与 docker_logs 同一口径,如 2h、2026-09-09T10:00:00);大日志强烈建议配 since/until 圈窗' },
2353
+ until: { type: 'string', description: '结束时间(docker --until 语法,与 since 同一口径)' },
2354
+ context: { type: 'number', description: '每个命中前后各保留的上下文行数(0~20,默认 2;堆栈需要更大值)' },
2355
+ limit: { type: 'number', description: '返回命中数上限(1~500,默认 200;matched 仍报全量命中数)' },
2356
+ tail: { type: 'number', description: '扫描行数上限(1~5000);缺省 = 扫全部历史(--tail all)' },
2357
+ timestamps: { type: 'boolean', description: '每行带时间戳(默认 true——深处的命中没有时间等于没用;传 false 关闭)' },
2358
+ },
2359
+ output: {
2360
+ schema: {
2361
+ type: 'object',
2362
+ additionalProperties: false,
2363
+ properties: {
2364
+ target: { type: 'string', required: true },
2365
+ id: { type: 'string', required: true },
2366
+ matched: { type: 'number', required: true },
2367
+ scanned: { type: 'number', required: true },
2368
+ returned: { type: 'number', required: true },
2369
+ limited: { type: 'boolean' },
2370
+ truncated: { type: 'boolean' },
2371
+ contextLines: { type: 'number', required: true },
2372
+ lines: {
2373
+ type: 'array',
2374
+ required: true,
2375
+ items: {
2376
+ type: 'object',
2377
+ additionalProperties: false,
2378
+ properties: {
2379
+ seq: { type: 'number', required: true },
2380
+ stream: { type: 'string', required: true },
2381
+ text: { type: 'string', required: true },
2382
+ hit: { type: 'boolean', required: true },
2383
+ highlight: { type: 'string' },
2384
+ },
2385
+ },
2386
+ },
2387
+ },
2388
+ },
2389
+ render: (_args, value) => {
2390
+ const v = value;
2391
+ const notes = [];
2392
+ if (v.limited === true)
2393
+ notes.push(`命中超过上限,只返回前几组窗口(总命中 ${String(v.matched ?? 0)},可加大 limit 或缩小时间窗)`);
2394
+ if (v.truncated === true)
2395
+ notes.push('扫描输出超过字节上限,最旧部分未参与检索(窗口偏新;请缩小 since/until)');
2396
+ 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`;
2397
+ const body = (v.lines ?? []).map((line) => `${String(line.seq ?? '?').padStart(6, ' ')} ${line.hit === true ? '>' : ' '} [${line.stream ?? 'out'}] ${line.text ?? ''}`).join('\n');
2398
+ return [{ type: 'text', text: head + (body === '' ? '(无命中)' : body) }];
2399
+ },
2400
+ },
2401
+ async execute(args) {
2402
+ const input = (args ?? {});
2403
+ const picked = pickTarget(input.target);
2404
+ if (picked.name === undefined)
2405
+ throw new Error(picked.error ?? '无效的 target');
2406
+ if (typeof input.id !== 'string')
2407
+ throw new Error('id 必填');
2408
+ if (typeof input.pattern !== 'string' || input.pattern === '')
2409
+ throw new Error('pattern 必填(要搜索的字面子串或正则)');
2410
+ const { api } = apiFor(picked.name);
2411
+ if (api === undefined)
2412
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
2413
+ // since/until 与 docker_logs / docker_events 同一口径(D45),同一个校验函数
2414
+ const result = await api.logsGrep(input.id, {
2415
+ pattern: input.pattern,
2416
+ ...(input.regexp === true ? { regexp: true } : {}),
2417
+ ...(input.ignoreCase === true ? { ignoreCase: true } : {}),
2418
+ ...(typeof input.since === 'string' && input.since.trim() !== '' ? { since: assertSince(input.since) } : {}),
2419
+ ...(typeof input.until === 'string' && input.until.trim() !== '' ? { until: assertSince(input.until, 'until') } : {}),
2420
+ ...(typeof input.context === 'number' && Number.isInteger(input.context) ? { context: input.context } : {}),
2421
+ ...(typeof input.limit === 'number' && Number.isInteger(input.limit) ? { limit: input.limit } : {}),
2422
+ ...(typeof input.tail === 'number' && Number.isInteger(input.tail) ? { tail: input.tail } : {}),
2423
+ ...(input.timestamps === false ? { timestamps: false } : {}),
2424
+ });
2425
+ return {
2426
+ target: picked.name,
2427
+ id: result.id,
2428
+ matched: result.matched,
2429
+ scanned: result.scanned,
2430
+ returned: result.returned,
2431
+ ...(result.limited ? { limited: true } : {}),
2432
+ ...(result.truncated ? { truncated: true } : {}),
2433
+ contextLines: result.contextLines,
2434
+ lines: result.lines.map((line) => ({
2435
+ seq: line.seq,
2436
+ stream: line.stream,
2437
+ text: line.text,
2438
+ hit: line.hit,
2439
+ ...(line.highlight === null ? {} : { highlight: line.highlight }),
2440
+ })),
2441
+ };
2442
+ },
2443
+ }));
2314
2444
  add('docker_stats', defineTool({
2315
2445
  name: 'docker_stats',
2316
2446
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
@@ -3034,6 +3164,7 @@ const plugin = definePlugin({
3034
3164
  }, {
3035
3165
  onStdout: (chunk) => coalescer.push('d', chunk),
3036
3166
  onStderr: (chunk) => coalescer.push('e', chunk),
3167
+ onStart: readyOnStart(sendEvent),
3037
3168
  }, signal);
3038
3169
  return result.code;
3039
3170
  }
@@ -3135,6 +3266,7 @@ const plugin = definePlugin({
3135
3266
  }
3136
3267
  },
3137
3268
  onStderr: (chunk) => sendEvent('line', { e: chunk }),
3269
+ onStart: readyOnStart(sendEvent),
3138
3270
  };
3139
3271
  const result = await api.statsStream(safeIds, handlers, signal);
3140
3272
  // 收尾:最后一个对象可能还没闭合(进程退出前只写了半行),能解析就发
@@ -3201,6 +3333,7 @@ const plugin = definePlugin({
3201
3333
  pending = pending.slice(-4096);
3202
3334
  },
3203
3335
  onStderr: (chunk) => sendEvent('line', { e: chunk }),
3336
+ onStart: readyOnStart(sendEvent),
3204
3337
  };
3205
3338
  const result = await api.eventsStream(handlers, signal);
3206
3339
  // 收尾:最后一行可能没换行符(进程退出前只写半行),能解析就发
@@ -3274,6 +3407,7 @@ const plugin = definePlugin({
3274
3407
  const result = await api.pullStream(safeRef, {
3275
3408
  onStdout: (chunk) => coalescer.push('d', chunk),
3276
3409
  onStderr: (chunk) => coalescer.push('e', chunk),
3410
+ onStart: readyOnStart(sendEvent),
3277
3411
  }, signal);
3278
3412
  auditCapabilityUse({ ...useFields, event: 'end', ok: true, durationMs: Date.now() - startedAt, reason: 'pull-exit', ...(result.code === null ? {} : { code: result.code }) });
3279
3413
  return result.code;