@hyzyn/dsh-docker 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +27 -5
- package/README.md +25 -6
- package/lib/docker.d.ts +90 -6
- package/lib/docker.js +122 -3
- package/lib/docker.js.map +1 -1
- package/lib/index.js +109 -2
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +11 -0
- package/lib/ssh-exec.js +20 -6
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +3 -3
- package/scripts/route-smoke.mjs +26 -4
package/README.en.md
CHANGED
|
@@ -608,7 +608,8 @@ and reconnect". The record list can be deleted / reset in the settings card (del
|
|
|
608
608
|
| `docker_ps` | always registered | `target?` (**pass `*` = all targets**), `all?: boolean` | lists containers (name / state / health / image / ports / compose project and service / short ID); by default only running ones, `all:true` includes stopped. With `target:'*'` it returns results grouped by target, and **one unreachable target does not affect the others** (that group carries `error`). Ports are merged across the IPv4/IPv6 dual-stack expansion (one `-p` no longer shows twice, D130); an empty `ports` does **not** mean "nothing exposed" — host-network containers publish on the host itself, and that case now carries a `net` field (D131). The first step of troubleshooting |
|
|
609
609
|
| `docker_attention` | always registered | `target?` (supports `*`), `limit?: number` | a **needs-attention summary**: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / zombie; every item carries `reasons`, `exitCode`, `oomKilled`, `restartCount`. OOM and the real exit code come from one `docker inspect` (a ps summary cannot distinguish a manual kill from 137). The troubleshooting entry point: call it first when you are unsure which machine or container to look at |
|
|
610
610
|
| `docker_inspect` | always registered | `target?`, `id` (required) | `docker inspect`'s authoritative details: state / health check / exit code / restart count / ports / mounts / networks / startup command |
|
|
611
|
-
| `docker_logs` | always registered | `target?`, `id`, `tail?` (1–5000, default `logTailDefault`), `timestamps?`, `since?` | the tail from `docker logs --tail`; `since`
|
|
611
|
+
| `docker_logs` | always registered | `target?`, `id`, `tail?` (1–5000, default `logTailDefault`), `timestamps?`, `since?`, `until?` | the tail from `docker logs --tail`; `since` / `until` use docker syntax (e.g. `10m`, `2026-09-09T10:00:00`) to bound a time window; over the cap it is marked `truncated` |
|
|
612
|
+
| `docker_logs_grep` | always registered | `target?`, `id`, `pattern` (required, ≤512 chars), `regexp?`, `ignoreCase?`, `since?`, `until?`, `context?` (0–20, default 2), `limit?` (1–500, default 200), `tail?` (omitted = scan all history), `timestamps?` (on by default) | **server-side log search**: line-by-line filtering in the host (never touches the remote shell, never pipes `| grep`), returns hit lines plus ±context lines, and honestly reports `matched` (all hits even when not returned) / `scanned` / `limited` / `truncated` — "only scanned this far" is distinguishable from "scanned everything"; the scan window is **aligned to line boundaries** (the partial line left by a byte-level cut is never returned as a line, so every entry in `lines` is a complete line). Division of labour with `docker_logs`: error in the recent tail → `docker_logs` (cheaper); buried deep / a specific time window / keywords, error codes, request IDs → this tool. Literal substring by default (grep -F semantics, zero ReDoS); JS regex only with `regexp: true` |
|
|
612
613
|
| `docker_stats` | always registered | `target?`, `ids?` (comma-separated container names/IDs) | a `docker stats --no-stream` snapshot: CPU% / memory usage and share / network IO / block IO / PIDs; omitting `ids` means all running containers. Live following is a panel capability (SSE), and the tool keeps single-value snapshot semantics |
|
|
613
614
|
| `docker_images` | always registered | `target?` | image list (repository:tag / size / created / short ID) |
|
|
614
615
|
| `docker_events` | always registered | `target?`, `since?` (docker `--since` syntax, default `10m`) | a container-event snapshot (`docker events --since <d> --until <now>`, likewise through the server-side allowlist): the nine kinds start / die / stop / kill / oom / health_status / destroy / rename / update, with noise such as `exec_*` already dropped server-side. For continuous observation have the user watch the "Activity" strip in the panel's container list |
|
|
@@ -637,7 +638,9 @@ and reconnect". The record list can be deleted / reset in the settings card (del
|
|
|
637
638
|
static switches above: the switches govern registration (host-level, persistent grant), the tier gate governs
|
|
638
639
|
each registered call (per call, follows the tier immediately). Mechanism:
|
|
639
640
|
[docs/permission-tier-plan.md](../../docs/permission-tier-plan.md).
|
|
640
|
-
- Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs`
|
|
641
|
+
- Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs`
|
|
642
|
+
(when the tail does not explain the failure, `docker_logs_grep` with `pattern` /
|
|
643
|
+
`since` / `until` to search deeper) →
|
|
641
644
|
`docker_inspect` → `docker_stats`; for image problems `docker_images` →
|
|
642
645
|
`docker_image_inspect`.
|
|
643
646
|
- With `announceToAgent` on, the plugin injects a capability announcement into the systemPrompt (including the
|
|
@@ -1028,7 +1031,7 @@ Host half (src/index.ts)
|
|
|
1028
1031
|
├─ image routes: /images/inspect (read-only) · /images/remove · /images/prune (allowMutations)
|
|
1029
1032
|
├─ network / volume routes: /networks · /volumes and their inspect (read-only), remove / prune (allowMutations)
|
|
1030
1033
|
└─ agent tools: docker_targets / docker_ps / docker_inspect /
|
|
1031
|
-
docker_logs (snapshot semantics unchanged) / docker_stats / docker_events / docker_images /
|
|
1034
|
+
docker_logs (snapshot semantics unchanged) / docker_logs_grep (server-side search) / docker_stats / docker_events / docker_images /
|
|
1032
1035
|
docker_image_inspect / docker_networks / docker_volumes (always registered)
|
|
1033
1036
|
+ docker_action / docker_image_remove / docker_image_prune / docker_image_pull
|
|
1034
1037
|
(allowMutations) / docker_exec (allowExec)
|
|
@@ -1162,8 +1165,8 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
|
|
|
1162
1165
|
should **name podman** and say "put podman in 'docker CLI' and it works", while **not**
|
|
1163
1166
|
rewriting `dockerBin` for you (go back to the settings card — the value is still `docker`).
|
|
1164
1167
|
4. **Read-only interception**: with both switches off, `/action`, `/exec`, `/images/remove`,
|
|
1165
|
-
`/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly
|
|
1166
|
-
registered (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` / `images` /
|
|
1168
|
+
`/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly 13 read-only tools are
|
|
1169
|
+
registered (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `logs_grep` / `stats` / `events` / `images` /
|
|
1167
1170
|
`image_inspect` / `networks` / `volumes` — see the tool table for the authoritative list), and
|
|
1168
1171
|
the panel's start / stop / remove, image removal, pruning and pull buttons are greyed out. After turning on
|
|
1169
1172
|
"allow mutations" these routes and tools appear immediately (no restart needed).
|
|
@@ -1291,6 +1294,25 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
|
|
|
1291
1294
|
Reverse check: switch to Target2 and then immediately back to Target1 (which still cannot connect) → the banner
|
|
1292
1295
|
should describe Target1's own
|
|
1293
1296
|
failure, not treat Target2's successful result as Target1's.
|
|
1297
|
+
21. **`docker_logs_grep` server-side search** (the shape used for the 2026-10-09 real-machine run): start a
|
|
1298
|
+
container whose log content you know in advance (any image you already have locally; no network needed):
|
|
1299
|
+
`docker run -d --rm --name dsh-grep-probe <local image> sh -c 'echo "[boot] starting"; for i in 1 2 3 4 5; do echo "tick $i ok"; done; echo "FATAL request-id=REQ-7788"; echo "WARN disk 91%" >&2; sleep 3600'`
|
|
1300
|
+
Then have the agent search it with `docker_logs_grep` and **check the raw return value item by item** (do not
|
|
1301
|
+
accept a paraphrase):
|
|
1302
|
+
- search `FATAL` → 1 hit, "scanned 8 lines", the hit marked `>` with ±2 context lines;
|
|
1303
|
+
- `tick [0-9]+` **without** `regexp` → 0 hits; with `regexp=true` → 5 hits (proves the default is **literal**);
|
|
1304
|
+
- `fatal` without `ignoreCase` → 0; with `ignoreCase=true` → 1;
|
|
1305
|
+
- `tick` + `limit=1` → "5 hits" reported but only one window returned, plus the "hits exceed the limit" note (`limited`);
|
|
1306
|
+
- `since=1h` hits / `until=1h` and `since=1s` both 0 hits **and "scanned 0 lines"** (proves `--since`/`--until` really pass through);
|
|
1307
|
+
- search `WARN` → the hit line's stream is `[err]`;
|
|
1308
|
+
- search a container that does not exist → docker's own error text, **not** an empty result.
|
|
1309
|
+
Forcing `truncated` needs a log **>8MB** (the 8MB scan floor is hard; lowering "output limit (KB)" does not move it):
|
|
1310
|
+
let a container write ~300k lines with the needles at the **very head** and the **very tail** → a full scan gives
|
|
1311
|
+
0 hits + the "scan output exceeded the byte limit" note for the former and a hit + the same note for the latter;
|
|
1312
|
+
adding `tail=5000` makes the note disappear in both (proving it reports a **real** scan-side overflow, not
|
|
1313
|
+
"the log is big so it is always true"). Also, **every returned line must be a complete line** (timestamp prefix):
|
|
1314
|
+
the byte gate cuts mid-line, and `lineAlign` drops that partial line (see the real-machine correction in ROADMAP)
|
|
1315
|
+
— a fragment such as `g line 174798` without a timestamp means this has regressed.
|
|
1294
1316
|
|
|
1295
1317
|
## Version / license
|
|
1296
1318
|
|
package/README.md
CHANGED
|
@@ -579,7 +579,8 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
|
|
|
579
579
|
| `docker_ps` | 恒注册 | `target?`(**传 `*` = 全部目标**)、`all?: boolean` | 列容器(名称 / 状态 / 健康 / 镜像 / 端口 / compose 项目与服务 / 短 ID);默认只列运行中,`all:true` 含已停止。`target:'*'` 时按目标分组返回,**单个目标不可达不影响其他目标**(该组带 `error`)。端口是 IPv4/IPv6 双栈归并后的映射(同一次 `-p` 不再出现两遍,D130);`ports` 为空**不等于**没暴露端口——host 网络容器的端口即宿主机端口,这种情况会给 `net` 字段(D131)。排障第一步 |
|
|
580
580
|
| `docker_attention` | 恒注册 | `target?`(支持 `*`)、`limit?: number` | **需关注汇总**:不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死;每条带 `reasons`、`exitCode`、`oomKilled`、`restartCount`。OOM 与真实退出码来自一次 `docker inspect`(ps 摘要里 137 无法区分手动 kill)。排障入口:不确定从哪台/哪个容器看起时先调它 |
|
|
581
581
|
| `docker_inspect` | 恒注册 | `target?`、`id`(必填) | `docker inspect` 的权威详情:状态 / 健康检查 / 退出码 / 重启次数 / 端口 / 挂载 / 网络 / 启动命令 |
|
|
582
|
-
| `docker_logs` | 恒注册 | `target?`、`id`、`tail?`(1~5000,默认 `logTailDefault`)、`timestamps?`、`since?` | `docker logs --tail` 尾部;`since` 用 docker 语法(如 `10m`、`2026-09-09T10:00:00
|
|
582
|
+
| `docker_logs` | 恒注册 | `target?`、`id`、`tail?`(1~5000,默认 `logTailDefault`)、`timestamps?`、`since?`、`until?` | `docker logs --tail` 尾部;`since` / `until` 用 docker 语法(如 `10m`、`2026-09-09T10:00:00`)圈时间窗;超上限标记 `truncated` |
|
|
583
|
+
| `docker_logs_grep` | 恒注册 | `target?`、`id`、`pattern`(必填,≤512 字符)、`regexp?`、`ignoreCase?`、`since?`、`until?`、`context?`(0~20,默认 2)、`limit?`(1~500,默认 200)、`tail?`(缺省 = 扫全部历史)、`timestamps?`(默认开) | **日志服务端检索**:宿主侧逐行过滤(不碰远端 shell、不拼 `\| grep`),返回命中行 + 前后上下文,并如实报告 `matched`(全量命中数,即使没返回)/ `scanned` / `limited` / `truncated`——「只扫到这些」与「扫完了没更多」可区分;扫描窗口**对齐到行边界**(按字节截断处的半行不当作一行返回,所以 `lines` 里每一行都是完整的行)。与 `docker_logs` 的分工:报错就在最近尾部 → `docker_logs`(便宜);埋在深处 / 特定时间窗 / 找关键词、异常码、请求 ID → 本工具。默认字面子串(grep -F 语义,零 ReDoS),`regexp: true` 才按 JS 正则 |
|
|
583
584
|
| `docker_stats` | 恒注册 | `target?`、`ids?`(逗号分隔的容器名/ID) | `docker stats --no-stream` 快照:CPU% / 内存用量与占比 / 网络 IO / 块 IO / PIDs;`ids` 省略 = 全部运行中容器。实时跟随是面板能力(SSE),工具保持单值快照语义 |
|
|
584
585
|
| `docker_images` | 恒注册 | `target?` | 镜像列表(仓库:标签 / 大小 / 创建时间 / 短 ID) |
|
|
585
586
|
| `docker_events` | 恒注册 | `target?`、`since?`(docker `--since` 语法,默认 `10m`) | 容器事件快照(`docker events --since <d> --until <now>`,同样过服务端白名单):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,`exec_*` 等噪音已在服务端丢掉。要持续观察请让用户看面板容器列表的「活动」条 |
|
|
@@ -605,7 +606,8 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
|
|
|
605
606
|
`tier-gate: services absent (…), per-call gate disabled`。它与上面两个静态开关是**双层**:
|
|
606
607
|
静态开关管注册(宿主级、持久授权),档位闸管已注册调用的放行(逐调用、随档位即时变)。
|
|
607
608
|
机制见 [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md)。
|
|
608
|
-
- 推荐排障顺序:`docker_targets` → `docker_ps` → `docker_logs
|
|
609
|
+
- 推荐排障顺序:`docker_targets` → `docker_ps` → `docker_logs`(尾部看不到成因就
|
|
610
|
+
`docker_logs_grep` 带 `pattern` / `since` / `until` 检索深处)→
|
|
609
611
|
`docker_inspect` → `docker_stats`;镜像排查 `docker_images` →
|
|
610
612
|
`docker_image_inspect`。
|
|
611
613
|
- `announceToAgent` 开启时,插件向 systemPrompt 注入一段能力公告(含「默认
|
|
@@ -977,7 +979,7 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
|
|
|
977
979
|
├─ 镜像路由:/images/inspect(只读)· /images/remove · /images/prune(allowMutations)
|
|
978
980
|
├─ 网络 / 卷路由:/networks · /volumes 与各自的 inspect(只读)、remove / prune(allowMutations)
|
|
979
981
|
└─ agent 工具:docker_targets / docker_ps / docker_inspect /
|
|
980
|
-
docker_logs(快照语义不变)/ docker_stats / docker_events / docker_images /
|
|
982
|
+
docker_logs(快照语义不变)/ docker_logs_grep(服务端检索)/ docker_stats / docker_events / docker_images /
|
|
981
983
|
docker_image_inspect / docker_networks / docker_volumes(恒注册)
|
|
982
984
|
+ docker_action / docker_image_remove / docker_image_prune / docker_image_pull
|
|
983
985
|
(allowMutations)/ docker_exec(allowExec)
|
|
@@ -1110,9 +1112,9 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
|
|
|
1110
1112
|
**点名 podman** 并说明「把『docker CLI』填成 podman 就能用」,同时**不该**替你
|
|
1111
1113
|
改 `dockerBin`(回头去设置卡片看,值仍是 `docker`)。
|
|
1112
1114
|
4. **只读拦截**:两个开关都关时,`/action`、`/exec`、`/images/remove`、
|
|
1113
|
-
`/images/prune`、`/images/pull/stream` 全部 403;agent 侧恒注册
|
|
1114
|
-
(`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` /
|
|
1115
|
-
`images` / `image_inspect` / `networks` / `volumes`,数量以工具表为准),
|
|
1115
|
+
`/images/prune`、`/images/pull/stream` 全部 403;agent 侧恒注册 13 个只读工具
|
|
1116
|
+
(`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` /
|
|
1117
|
+
`logs_grep` / `stats` / `events` / `images` / `image_inspect` / `networks` / `volumes`,数量以工具表为准),
|
|
1116
1118
|
面板的启停删 / 镜像删除 / 清理 / 拉取按钮置灰。打开「允许变更操作」后这些
|
|
1117
1119
|
路由与工具立即出现(无需重启)。
|
|
1118
1120
|
5. **日志 / 统计 / 镜像**:`tail` 与 `timestamps` / `since` 生效;统计显示
|
|
@@ -1205,6 +1207,23 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
|
|
|
1205
1207
|
目标1 的容器。从总览点 目标1 那张红色计数卡进去,同样不该看到上一个目标的失败。
|
|
1206
1208
|
反向验证:切到目标2 后立刻切回目标1(它仍然连不上)→ 横幅应写的是目标1 自己的
|
|
1207
1209
|
失败,而不是把目标2 的成功结果当成了目标1 的。
|
|
1210
|
+
21. **`docker_logs_grep` 服务端检索**(2026-10-09 真机实测的口径):起一个日志内容已知的容器
|
|
1211
|
+
(用本地已有镜像即可,不必联网):
|
|
1212
|
+
`docker run -d --rm --name dsh-grep-probe <本地镜像> sh -c 'echo "[boot] starting"; for i in 1 2 3 4 5; do echo "tick $i ok"; done; echo "FATAL request-id=REQ-7788"; echo "WARN disk 91%" >&2; sleep 3600'`
|
|
1213
|
+
然后在会话里让 agent 用 `docker_logs_grep` 检索它,**逐项看原始返回值**(别接受转述):
|
|
1214
|
+
- 找 `FATAL` → 命中 1 行、「扫描 8 行」、命中行标 `>` 且带 ±2 行上下文;
|
|
1215
|
+
- `tick [0-9]+` **不传** `regexp` → 0 命中;传 `regexp=true` → 5 命中(证明默认是**字面**匹配);
|
|
1216
|
+
- `fatal` 不传 `ignoreCase` → 0;传 `ignoreCase=true` → 1;
|
|
1217
|
+
- `tick` + `limit=1` → 「命中 5 行」但只返回 1 组窗口,并出现「命中超过上限」提示(`limited`);
|
|
1218
|
+
- `since=1h` 命中 / `until=1h` 与 `since=1s` 各 0 命中**且「扫描 0 行」**(证明 `--since`/`--until` 真透传);
|
|
1219
|
+
- 找 `WARN` → 命中行的 stream 是 `[err]`;
|
|
1220
|
+
- 检索不存在的容器 → 报 docker 原文错误,**不是**空结果。
|
|
1221
|
+
`truncated` 只能用 **>8MB** 日志逼出来(扫描侧 8MB 硬下限,调小「输出上限(KB)」没用):
|
|
1222
|
+
让容器打 ~30 万行、探针分别写在**最开头**与**最末尾** → 全量扫时前者 0 命中 + 「扫描输出超过
|
|
1223
|
+
字节上限」提示,后者命中 + 同一提示;加 `tail=5000` 后两者都不再出现该提示(证明它报的是
|
|
1224
|
+
扫描侧**真溢出**,不是「日志大就恒 true」)。另外**返回的每一行都必须是完整行**(带时间戳
|
|
1225
|
+
前缀):字节闸的切口会切在行中间,`lineAlign` 负责丢掉那个半行(见 ROADMAP 的真机验收补正)
|
|
1226
|
+
—— 看到 `g line 174798` 这种没时间戳的碎片,就是这条回归了。
|
|
1208
1227
|
|
|
1209
1228
|
## 版本 / 许可证
|
|
1210
1229
|
|
package/lib/docker.d.ts
CHANGED
|
@@ -348,6 +348,22 @@ export interface ContainerDetail {
|
|
|
348
348
|
export declare function parseInspectJson(text: string): ContainerDetail[];
|
|
349
349
|
/** inspect 的 `NetworkSettings.Ports`:`{ "80/tcp": [{HostIp, HostPort}] }`。 */
|
|
350
350
|
export declare function parseInspectPorts(value: unknown): PortMapping[];
|
|
351
|
+
/**
|
|
352
|
+
* 一次性命令的选项**子集**(Runner 面):= `ExecOptions` 去掉 `input`(这条通道喂不了
|
|
353
|
+
* stdin)并加上 `signal`。
|
|
354
|
+
*
|
|
355
|
+
* 刻意写成具名类型:它原先在 `Runner.run` 与 `createRunner` 的 `withSignal` 里各写了一份
|
|
356
|
+
* 字面量,于是新增一个字段只能靠对象字面量处的 tsc 报错兜住——`lineAlign` 那次就是先漏在
|
|
357
|
+
* 第二份上。具名类型之后不存在「第二份」。
|
|
358
|
+
*/
|
|
359
|
+
export interface RunnerRunOptions {
|
|
360
|
+
timeoutMs?: number;
|
|
361
|
+
maxBytes?: number;
|
|
362
|
+
keepTail?: boolean;
|
|
363
|
+
/** 截断后对齐到行边界(见 `ExecOptions.lineAlign`);只有 `docker_logs_grep` 用。 */
|
|
364
|
+
lineAlign?: boolean;
|
|
365
|
+
signal?: AbortSignal;
|
|
366
|
+
}
|
|
351
367
|
/** 一个目标(target)背后的命令执行通道。 */
|
|
352
368
|
export interface Runner {
|
|
353
369
|
/** 展示用标签:`本机` 或 `user@host`。 */
|
|
@@ -358,12 +374,7 @@ export interface Runner {
|
|
|
358
374
|
* 目前只有跨目标聚合(`aggregateAcrossTargets`)会传——单目标路径的截止时间是
|
|
359
375
|
* `timeoutMs`,它本来就够。
|
|
360
376
|
*/
|
|
361
|
-
run(argv: readonly string[], options?:
|
|
362
|
-
timeoutMs?: number;
|
|
363
|
-
maxBytes?: number;
|
|
364
|
-
keepTail?: boolean;
|
|
365
|
-
signal?: AbortSignal;
|
|
366
|
-
}): Promise<ExecResult>;
|
|
377
|
+
run(argv: readonly string[], options?: RunnerRunOptions): Promise<ExecResult>;
|
|
367
378
|
/** 长流(logs --follow):逐块回调,signal 中止;无总超时与输出上限。 */
|
|
368
379
|
stream(argv: readonly string[], handlers: StreamHandlers, signal?: AbortSignal): Promise<StreamResult>;
|
|
369
380
|
}
|
|
@@ -375,9 +386,65 @@ export interface LogsOptions {
|
|
|
375
386
|
tail?: number;
|
|
376
387
|
timestamps?: boolean;
|
|
377
388
|
since?: string;
|
|
389
|
+
/** 结束时间(docker --until 语法:ISO 8601 时间戳或 Go duration,如 2026-09-09T10:00:00、1h)。*/
|
|
390
|
+
until?: string;
|
|
378
391
|
/** 实时跟随(`docker logs --follow`):仅 logsStream() 使用,logs() 忽略。 */
|
|
379
392
|
follow?: boolean;
|
|
380
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* 日志检索选项:**默认字面子串**匹配(grep -F 等价),零 ReDoS 风险;
|
|
396
|
+
* `regexp: true` 时才按 JS 正则解释 pattern(仍建议配 since/until 收窄窗口)。
|
|
397
|
+
*/
|
|
398
|
+
export interface LogGrepOptions {
|
|
399
|
+
/** 要搜索的字面子串,或(regexp=true 时)正则表达式源文。 */
|
|
400
|
+
pattern: string;
|
|
401
|
+
/** true = 把 pattern 当正则;默认 false = 字面子串。 */
|
|
402
|
+
regexp?: boolean;
|
|
403
|
+
/** 忽略大小写(字面模式 = grep -i,正则模式 = /i 标志)。 */
|
|
404
|
+
ignoreCase?: boolean;
|
|
405
|
+
/** 扫描行数上限(1~5000);缺省 = `--tail all` 扫全部历史。 */
|
|
406
|
+
tail?: number;
|
|
407
|
+
/** 起始时间(与 docker_logs 的 since 同一口径,D45)。 */
|
|
408
|
+
since?: string;
|
|
409
|
+
/** 结束时间(docker --until 语法,与 since 同一口径)。 */
|
|
410
|
+
until?: string;
|
|
411
|
+
/** 每个命中前后各保留的上下文行数(0~20,默认 2)。 */
|
|
412
|
+
context?: number;
|
|
413
|
+
/** 返回命中数上限(1~500,默认 200)。 */
|
|
414
|
+
limit?: number;
|
|
415
|
+
/** 时间戳(默认开——深处的命中没有时间等于没用)。 */
|
|
416
|
+
timestamps?: boolean;
|
|
417
|
+
}
|
|
418
|
+
/** 检索结果里的一行(命中行或它的上下文行)。 */
|
|
419
|
+
export interface LogGrepLine {
|
|
420
|
+
/** 该通道内的行号(从 1 开始;stdout 在前、stderr 在后,与 logs() 的拼接序一致)。 */
|
|
421
|
+
seq: number;
|
|
422
|
+
stream: 'out' | 'err';
|
|
423
|
+
text: string;
|
|
424
|
+
/** 这行本身是否命中(false = 只是上下文)。 */
|
|
425
|
+
hit: boolean;
|
|
426
|
+
/** 命中时:匹配到的子串(供调用方定位;上下文行为 null)。 */
|
|
427
|
+
highlight: string | null;
|
|
428
|
+
}
|
|
429
|
+
/** 日志检索结果。两个「截断」要分清:truncated = 扫描侧字节闸截断,limited = 命中数达到 limit。 */
|
|
430
|
+
export interface LogGrepResult {
|
|
431
|
+
id: string;
|
|
432
|
+
/** 命中总行数(即使超过 limit 也如实计数)。 */
|
|
433
|
+
matched: number;
|
|
434
|
+
/** 实际扫描的行数。 */
|
|
435
|
+
scanned: number;
|
|
436
|
+
/** 返回的行数(命中 + 去重后的上下文)。 */
|
|
437
|
+
returned: number;
|
|
438
|
+
/** 命中数达到 limit、还有命中没返回。 */
|
|
439
|
+
limited: boolean;
|
|
440
|
+
/**
|
|
441
|
+
* 扫描输出超过字节上限(按**字节**丢最旧——窗口偏新,缩小 since/until 或换目标时段)。
|
|
442
|
+
* 窗口已对齐到行边界,所以 `lines` 里不会出现半行;但切口之前的内容确实没参与检索。
|
|
443
|
+
*/
|
|
444
|
+
truncated: boolean;
|
|
445
|
+
contextLines: number;
|
|
446
|
+
lines: LogGrepLine[];
|
|
447
|
+
}
|
|
381
448
|
export interface ProbeResult {
|
|
382
449
|
ok: boolean;
|
|
383
450
|
bin: string;
|
|
@@ -586,9 +653,26 @@ export declare class DockerApi {
|
|
|
586
653
|
* 只要新行、不要重放历史;`--tail 0` 正是不补历史、只跟随的原生语义)。
|
|
587
654
|
*/
|
|
588
655
|
logsStream(id: string, options: LogsOptions | undefined, handlers: StreamHandlers, signal?: AbortSignal): Promise<StreamResult>;
|
|
656
|
+
/**
|
|
657
|
+
* 日志检索(grep):**宿主侧逐行过滤,不碰远端 shell**——绝不拼 `| grep`
|
|
658
|
+
* (那会违反「argv 构造、绝不做字符串拼接」的铁律,且 SSH 路径的转义面完全不同)。
|
|
659
|
+
*
|
|
660
|
+
* 与 logs() 的分工(写进工具描述,供模型选择):「报错在最近尾部」→ docker_logs;
|
|
661
|
+
* 「埋在深处 / 特定时间窗 / 找关键词」→ docker_logs_grep。扫描与返回的预算是**分开**
|
|
662
|
+
* 的:扫描侧给到 8~32MB(maxOutputKb 只是单命令返回闸,grep 的意义就是从大量原文里
|
|
663
|
+
* 筛出少量命中),返回侧由 limit × context 夹住;两侧截断各有独立信号位
|
|
664
|
+
* (truncated = 扫描侧溢出丢最旧、limited = 命中超过 limit),都不静默。扫描窗口
|
|
665
|
+
* **对齐到行边界**(`lineAlign`):切口处的半行不当作一行返回,否则它会以没有时间戳的
|
|
666
|
+
* 噪音形式进模型上下文,看起来像「检索结果里有脏行」。
|
|
667
|
+
*
|
|
668
|
+
* 默认字面子串匹配(grep -F 等价,零 ReDoS);regexp=true 才按 JS 正则解释,
|
|
669
|
+
* pattern 上限 512 字符。时间戳默认开——深处的命中没有时间等于没用。
|
|
670
|
+
*/
|
|
671
|
+
logsGrep(id: string, options: LogGrepOptions): Promise<LogGrepResult>;
|
|
589
672
|
/**
|
|
590
673
|
* 日志 argv 的唯一构造点:快照与流式只在 `--follow` 与 tail 下限上有差异,
|
|
591
674
|
* 校验与夹紧必须逐字一致(否则同一 id 在两条路径上行为漂移)。
|
|
675
|
+
* `tailOverride` 供 logsGrep 传 `all`(检索要扫全部历史,而不是尾部 5000 行)。
|
|
592
676
|
*/
|
|
593
677
|
private logsArgv;
|
|
594
678
|
/** 生命周期操作;调用方负责 readOnly / allowMutations 门禁。 */
|
package/lib/docker.js
CHANGED
|
@@ -1421,22 +1421,141 @@ export class DockerApi {
|
|
|
1421
1421
|
const safe = assertRef(id, 'container');
|
|
1422
1422
|
return await this.runner.stream(this.logsArgv(safe, options, true), handlers, signal);
|
|
1423
1423
|
}
|
|
1424
|
+
/**
|
|
1425
|
+
* 日志检索(grep):**宿主侧逐行过滤,不碰远端 shell**——绝不拼 `| grep`
|
|
1426
|
+
* (那会违反「argv 构造、绝不做字符串拼接」的铁律,且 SSH 路径的转义面完全不同)。
|
|
1427
|
+
*
|
|
1428
|
+
* 与 logs() 的分工(写进工具描述,供模型选择):「报错在最近尾部」→ docker_logs;
|
|
1429
|
+
* 「埋在深处 / 特定时间窗 / 找关键词」→ docker_logs_grep。扫描与返回的预算是**分开**
|
|
1430
|
+
* 的:扫描侧给到 8~32MB(maxOutputKb 只是单命令返回闸,grep 的意义就是从大量原文里
|
|
1431
|
+
* 筛出少量命中),返回侧由 limit × context 夹住;两侧截断各有独立信号位
|
|
1432
|
+
* (truncated = 扫描侧溢出丢最旧、limited = 命中超过 limit),都不静默。扫描窗口
|
|
1433
|
+
* **对齐到行边界**(`lineAlign`):切口处的半行不当作一行返回,否则它会以没有时间戳的
|
|
1434
|
+
* 噪音形式进模型上下文,看起来像「检索结果里有脏行」。
|
|
1435
|
+
*
|
|
1436
|
+
* 默认字面子串匹配(grep -F 等价,零 ReDoS);regexp=true 才按 JS 正则解释,
|
|
1437
|
+
* pattern 上限 512 字符。时间戳默认开——深处的命中没有时间等于没用。
|
|
1438
|
+
*/
|
|
1439
|
+
async logsGrep(id, options) {
|
|
1440
|
+
const safe = assertRef(id, 'container');
|
|
1441
|
+
if (typeof options.pattern !== 'string' || options.pattern === '')
|
|
1442
|
+
throw new Error('pattern 必填(要搜索的字面子串或正则表达式)');
|
|
1443
|
+
if (options.pattern.length > 512)
|
|
1444
|
+
throw new Error('pattern 过长(≤512 字符)');
|
|
1445
|
+
const useRegexp = options.regexp === true;
|
|
1446
|
+
const ignoreCase = options.ignoreCase === true;
|
|
1447
|
+
/** 单行判定:命中返回匹配子串(用作 highlight),未命中返回 null。 */
|
|
1448
|
+
let testLine;
|
|
1449
|
+
if (useRegexp) {
|
|
1450
|
+
let re;
|
|
1451
|
+
try {
|
|
1452
|
+
re = new RegExp(options.pattern, ignoreCase ? 'i' : '');
|
|
1453
|
+
}
|
|
1454
|
+
catch (error) {
|
|
1455
|
+
throw new Error(`无效的正则表达式:${error instanceof Error ? error.message : String(error)}`);
|
|
1456
|
+
}
|
|
1457
|
+
testLine = (line) => {
|
|
1458
|
+
const match = re.exec(line);
|
|
1459
|
+
return match === null ? null : match[0];
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
const needle = ignoreCase ? options.pattern.toLowerCase() : options.pattern;
|
|
1464
|
+
testLine = (line) => {
|
|
1465
|
+
const hay = ignoreCase ? line.toLowerCase() : line;
|
|
1466
|
+
const at = hay.indexOf(needle);
|
|
1467
|
+
return at === -1 ? null : line.slice(at, at + needle.length);
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
const contextLines = Math.min(Math.max(Math.trunc(options.context ?? 2), 0), 20);
|
|
1471
|
+
const limit = Math.min(Math.max(Math.trunc(options.limit ?? 200), 1), 500);
|
|
1472
|
+
// tail 缺省 = all(docker logs --tail all);给了数字就夹到 1~5000——检索的默认语义是「全历史里找」
|
|
1473
|
+
const tailOverride = typeof options.tail === 'number' && Number.isInteger(options.tail) && options.tail > 0
|
|
1474
|
+
? String(Math.min(options.tail, 5000))
|
|
1475
|
+
: 'all';
|
|
1476
|
+
const argv = this.logsArgv(safe, {
|
|
1477
|
+
timestamps: options.timestamps !== false,
|
|
1478
|
+
...(typeof options.since === 'string' && options.since.trim() !== '' ? { since: options.since.trim() } : {}),
|
|
1479
|
+
...(typeof options.until === 'string' && options.until.trim() !== '' ? { until: options.until.trim() } : {}),
|
|
1480
|
+
}, false, tailOverride);
|
|
1481
|
+
// 扫描预算独立于返回预算:下限 8MB、上限 32MB(宿主内存有界),超时给到 60s 档
|
|
1482
|
+
const scanBytes = Math.min(Math.max(this.limits.maxBytes, 8 * 1024 * 1024), 32 * 1024 * 1024);
|
|
1483
|
+
const result = await this.runner.run(argv, {
|
|
1484
|
+
timeoutMs: Math.max(this.limits.timeoutMs, 60_000),
|
|
1485
|
+
maxBytes: scanBytes,
|
|
1486
|
+
keepTail: true, // 溢出丢最旧(与 logs() 同方向,D14);报告里如实说明窗口偏新
|
|
1487
|
+
// 再对齐到行边界:保留尾部是按字节切的,不对齐的话窗口第一行是**半行**(没有时间戳、
|
|
1488
|
+
// 内容从中途开始),会以噪音形式进模型上下文。理由与边界见 ExecOptions.lineAlign。
|
|
1489
|
+
lineAlign: true,
|
|
1490
|
+
});
|
|
1491
|
+
this.assertOk(result, '检索容器日志');
|
|
1492
|
+
// 拆行(stdout 在前、stderr 在后,与 logs() 的拼接序一致;只去掉块尾的空行)
|
|
1493
|
+
const splitChunk = (text) => {
|
|
1494
|
+
const lines = text.split('\n');
|
|
1495
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
1496
|
+
lines.pop();
|
|
1497
|
+
return lines;
|
|
1498
|
+
};
|
|
1499
|
+
const rows = [
|
|
1500
|
+
...splitChunk(result.stdout).map((text) => ({ text, stream: 'out' })),
|
|
1501
|
+
...splitChunk(result.stderr).map((text) => ({ text, stream: 'err' })),
|
|
1502
|
+
];
|
|
1503
|
+
// 全量扫命中(matched 如实计数,即使超过 limit);limit 只决定「返回哪些窗」
|
|
1504
|
+
const hitIndexes = [];
|
|
1505
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1506
|
+
if (testLine(rows[i].text) !== null)
|
|
1507
|
+
hitIndexes.push(i);
|
|
1508
|
+
}
|
|
1509
|
+
const limited = hitIndexes.length > limit;
|
|
1510
|
+
const keptHits = hitIndexes.slice(0, limit);
|
|
1511
|
+
// 上下文窗口合并去重(相邻命中共享的行只输出一份),按原序输出
|
|
1512
|
+
const take = new Set();
|
|
1513
|
+
const hitSet = new Set(keptHits);
|
|
1514
|
+
for (const idx of keptHits) {
|
|
1515
|
+
for (let j = Math.max(0, idx - contextLines); j <= Math.min(rows.length - 1, idx + contextLines); j++)
|
|
1516
|
+
take.add(j);
|
|
1517
|
+
}
|
|
1518
|
+
const lines = [...take]
|
|
1519
|
+
.sort((a, b) => a - b)
|
|
1520
|
+
.map((j) => {
|
|
1521
|
+
const row = rows[j];
|
|
1522
|
+
return {
|
|
1523
|
+
seq: j + 1,
|
|
1524
|
+
stream: row.stream,
|
|
1525
|
+
text: row.text,
|
|
1526
|
+
hit: hitSet.has(j),
|
|
1527
|
+
highlight: hitSet.has(j) ? testLine(row.text) : null,
|
|
1528
|
+
};
|
|
1529
|
+
});
|
|
1530
|
+
return {
|
|
1531
|
+
id: safe,
|
|
1532
|
+
matched: hitIndexes.length,
|
|
1533
|
+
scanned: rows.length,
|
|
1534
|
+
returned: lines.length,
|
|
1535
|
+
limited,
|
|
1536
|
+
truncated: result.truncated,
|
|
1537
|
+
contextLines,
|
|
1538
|
+
lines,
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1424
1541
|
/**
|
|
1425
1542
|
* 日志 argv 的唯一构造点:快照与流式只在 `--follow` 与 tail 下限上有差异,
|
|
1426
1543
|
* 校验与夹紧必须逐字一致(否则同一 id 在两条路径上行为漂移)。
|
|
1544
|
+
* `tailOverride` 供 logsGrep 传 `all`(检索要扫全部历史,而不是尾部 5000 行)。
|
|
1427
1545
|
*/
|
|
1428
|
-
logsArgv(id, options, follow) {
|
|
1546
|
+
logsArgv(id, options, follow, tailOverride) {
|
|
1429
1547
|
// 流式允许 0(D133:重连只补新行);快照路径保持 1 起步——「取 0 行快照」没有意义
|
|
1430
1548
|
const min = follow ? 0 : 1;
|
|
1431
|
-
const tail = Math.min(Math.max(Math.trunc(options?.tail ?? 200), min), 5000);
|
|
1549
|
+
const tail = tailOverride ?? String(Math.min(Math.max(Math.trunc(options?.tail ?? 200), min), 5000));
|
|
1432
1550
|
return [
|
|
1433
1551
|
this.bin,
|
|
1434
1552
|
'logs',
|
|
1435
1553
|
...(follow ? ['--follow'] : []),
|
|
1436
1554
|
'--tail',
|
|
1437
|
-
|
|
1555
|
+
tail,
|
|
1438
1556
|
...(options?.timestamps === true ? ['--timestamps'] : []),
|
|
1439
1557
|
...(typeof options?.since === 'string' && options.since.trim() !== '' ? ['--since', options.since.trim()] : []),
|
|
1558
|
+
...(typeof options?.until === 'string' && options.until.trim() !== '' ? ['--until', options.until.trim()] : []),
|
|
1440
1559
|
id,
|
|
1441
1560
|
];
|
|
1442
1561
|
}
|