@hyzyn/dsh-docker 0.9.5 → 0.10.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 +157 -31
- package/README.md +146 -30
- package/client-src/docker.css +343 -160
- package/client-src/elevation.js +163 -0
- package/client-src/index.js +563 -119
- package/client.js +367 -183
- package/lib/audit.d.ts +106 -0
- package/lib/audit.js +140 -0
- package/lib/audit.js.map +1 -0
- package/lib/docker.d.ts +32 -3
- package/lib/docker.js +43 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +53 -0
- package/lib/index.js +473 -25
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +9 -0
- package/lib/ssh-exec.js +149 -16
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +2 -2
- package/scripts/client-smoke.mjs +394 -0
- package/scripts/log-perf.mjs +9 -2
- package/scripts/route-smoke.mjs +216 -5
package/lib/index.js
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { delimiter, basename, join } from 'node:path';
|
|
2
3
|
import z from '@deepseek-ai/schemastery';
|
|
3
4
|
import { definePlugin, hasSameOriginProof, isLoopbackRequestStrict, originProofHint, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
4
5
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
6
|
+
import { auditCapabilityUse, audited } from './audit.js';
|
|
5
7
|
import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
|
|
6
8
|
import { RemoteExec, sanitizeJumpSpec, sanitizeProxyCommand, setCredentialResolver, sshTarget } from './ssh-exec.js';
|
|
7
|
-
import { auditLoadedGrants, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGranted, capabilityGrantVia, capabilityPaths, createElevationManager, dshHome, sharedGrantStore, } from '@hyzyn/dsh-kit';
|
|
9
|
+
import { auditLoadedGrants, attachTierGate, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGranted, capabilityGrantVia, capabilityPaths, createElevationManager, dshHome, sharedGrantStore, } from '@hyzyn/dsh-kit';
|
|
10
|
+
/*
|
|
11
|
+
* 档位闸的分类表(docs/permission-tier-plan.md §2.1):`read` 纯读 / 固定二进制探测
|
|
12
|
+
* (docker_connect_local 不在能力开关里,见 connectLocal 的注释);`write` 容器与镜像的变更
|
|
13
|
+
* (docker_image_pull 是拉取非破坏,但改本地状态,且本就注册在 allowMutations 组里);
|
|
14
|
+
* `exec` 容器内一次性命令。名单必须与 refreshTools 里 add() 注册的工集一一对应——对账由
|
|
15
|
+
* test/tier-gate.test.ts 从 add() 调用处现算(漏分类 → 红)。与 allowMutations / allowExec
|
|
16
|
+
* 的关系是**双层并存**:静态开关管注册(宿主级、持久授权),档位闸管已注册调用的放行
|
|
17
|
+
* (逐调用、会话级),谁也不取代谁。
|
|
18
|
+
*/
|
|
19
|
+
export const DOCKER_TIER_CLASS = {
|
|
20
|
+
docker_targets: 'read',
|
|
21
|
+
docker_connect_local: 'read',
|
|
22
|
+
docker_ps: 'read',
|
|
23
|
+
docker_attention: 'read',
|
|
24
|
+
docker_inspect: 'read',
|
|
25
|
+
docker_logs: 'read',
|
|
26
|
+
docker_stats: 'read',
|
|
27
|
+
docker_events: 'read',
|
|
28
|
+
docker_images: 'read',
|
|
29
|
+
docker_image_inspect: 'read',
|
|
30
|
+
docker_networks: 'read',
|
|
31
|
+
docker_volumes: 'read',
|
|
32
|
+
docker_action: 'write',
|
|
33
|
+
docker_image_remove: 'write',
|
|
34
|
+
docker_image_prune: 'write',
|
|
35
|
+
docker_image_pull: 'write',
|
|
36
|
+
docker_exec: 'exec',
|
|
37
|
+
};
|
|
38
|
+
/** 本插件 agent 工具的名单前缀(档位闸只听这些名字)。 */
|
|
39
|
+
export const DOCKER_TIER_PREFIXES = ['docker_'];
|
|
8
40
|
const TARGET_SCHEMA = z.object({
|
|
9
41
|
name: z.string().required(),
|
|
10
42
|
kind: z.union([z.const('local'), z.const('ssh')]).default('local'),
|
|
@@ -89,6 +121,14 @@ const MUTATION_SUBROUTES = new Set([
|
|
|
89
121
|
'/elevate',
|
|
90
122
|
'/elevate/status',
|
|
91
123
|
'/elevate/revoke',
|
|
124
|
+
/*
|
|
125
|
+
* 一键连接本机(POST /connect-local):它**会写插件配置**(加一个本机目标),
|
|
126
|
+
* 与其它写路径同一档,必须带同源证明——跨站页面不该有权替用户改目标列表。
|
|
127
|
+
* 注意它**不**在能力开关(allowMutations / allowExec)里:见 connectLocal 的注释。
|
|
128
|
+
* 客户端只在「一个目标都没有」的空态里给入口,但路由本身与调用方无关(agent 工具
|
|
129
|
+
* 在任何时候都能调),所以这条守卫不能省。
|
|
130
|
+
*/
|
|
131
|
+
'/connect-local',
|
|
92
132
|
]);
|
|
93
133
|
/** 「这条错误来自目标侧」的标记(见 guardTargetFailures)。 */
|
|
94
134
|
const TARGET_FAILURE = Symbol('dsh-docker.target-failure');
|
|
@@ -132,7 +172,7 @@ function guardTargetFailures(api) {
|
|
|
132
172
|
function isTargetFailure(error) {
|
|
133
173
|
return error !== null && typeof error === 'object' && error[TARGET_FAILURE] === true;
|
|
134
174
|
}
|
|
135
|
-
const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets
|
|
175
|
+
const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。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 权限,不要在用户未明确要求时执行变更操作。';
|
|
136
176
|
/**
|
|
137
177
|
* SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
|
|
138
178
|
* 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
|
|
@@ -279,6 +319,120 @@ export function sanitizeTargets(input) {
|
|
|
279
319
|
}
|
|
280
320
|
return out;
|
|
281
321
|
}
|
|
322
|
+
/** 一键连接本机时新建目标的基准名。刻意用稳定的小写 `local`(不是 `本机`):它是可预测的 id,重名时另起 `local-2`…。 */
|
|
323
|
+
export const LOCAL_TARGET_NAME = 'local';
|
|
324
|
+
/** 目标列表里第一个 `kind=local` 的名称(没有则 undefined)——「连接本机」靠它判断该新建还是复用。 */
|
|
325
|
+
export function findLocalTargetName(targets) {
|
|
326
|
+
return targets.find((item) => item.kind === 'local')?.name;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* 新建本机目标时挑一个**确定性无冲突**的名称:`local` 被占就顺着 `local-2`、`local-3`… 找第一个空位。
|
|
330
|
+
*
|
|
331
|
+
* 为什么不做随机 / 时间戳后缀:同一个工作区里重复点「连接本机」应当**幂等**(第二次复用第一次那条),
|
|
332
|
+
* 而重名只可能来自用户自己配的其它 `local*`;顺序探测既确定又与用户已有的名字不冲突(不覆盖、不改名)。
|
|
333
|
+
* 上界 1000 是防御性截断(真到那时说明列表已经病态,宁可失败也不能无限循环)。
|
|
334
|
+
*/
|
|
335
|
+
export function nextLocalTargetName(taken) {
|
|
336
|
+
const used = new Set(taken);
|
|
337
|
+
if (!used.has(LOCAL_TARGET_NAME))
|
|
338
|
+
return LOCAL_TARGET_NAME;
|
|
339
|
+
for (let index = 2; index <= 1000; index += 1) {
|
|
340
|
+
const candidate = `${LOCAL_TARGET_NAME}-${String(index)}`;
|
|
341
|
+
if (!used.has(candidate))
|
|
342
|
+
return candidate;
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* 「探不到 CLI」的判据(`runLocal` 的 ENOENT 抛错形态)。
|
|
348
|
+
*
|
|
349
|
+
* 抽出来当单一来源:文案分档与「要不要去找 podman」都用它,两处各写一遍正则
|
|
350
|
+
* 迟早会漂移——那时会出现「文案说 CLI 缺失、但不去找候选」或反过来。
|
|
351
|
+
*/
|
|
352
|
+
export function isLocalCliMissing(raw) {
|
|
353
|
+
return /ENOENT|not recognized as an internal|command not found/i.test(raw);
|
|
354
|
+
}
|
|
355
|
+
/** 本机容器 CLI 的候选(除 `dockerBin` 自己之外):按这个顺序找第一个存在的。 */
|
|
356
|
+
const LOCAL_CLI_ALTERNATIVES = ['docker', 'podman', 'nerdctl'];
|
|
357
|
+
/**
|
|
358
|
+
* 在 PATH 里找**别的**容器 CLI(`dockerBin` 缺失时的候选),返回名字数组(可能为空)。
|
|
359
|
+
*
|
|
360
|
+
* 只读探测,**绝不改配置**:返回的候选交给文案去说「你可以把 docker CLI 改成它」,
|
|
361
|
+
* 改不改由用户在设置卡片决定。静默改写 `dockerBin` 会违背本插件「不替用户决定」的
|
|
362
|
+
* 一贯取向(何况 podman 与本插件的输出格式兼容性并未逐项验证,见 README)。
|
|
363
|
+
*
|
|
364
|
+
* `env` 可注入(测试用);Windows 按 PATHEXT 补扩展名——本插件明确支持 Windows
|
|
365
|
+
* 盘符路径,不能只认无扩展名的 POSIX 查找。
|
|
366
|
+
*/
|
|
367
|
+
export function findAlternativeLocalCli(bin, env = process.env) {
|
|
368
|
+
const dirs = (env.PATH ?? '').split(delimiter).filter((item) => item !== '');
|
|
369
|
+
const win = process.platform === 'win32';
|
|
370
|
+
const exts = win ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter((item) => item !== '') : [''];
|
|
371
|
+
/*
|
|
372
|
+
* 比的是**工具名**(basename,Windows 再去掉扩展名),不是配置里的原字符串:
|
|
373
|
+
* `dockerBin` 常写成绝对路径(`/usr/local/bin/docker`),拿它直接比对候选名会
|
|
374
|
+
* 把「同一个 docker」当成可切换的候选,给出「把 docker CLI 改成 docker」这种废话。
|
|
375
|
+
*/
|
|
376
|
+
const stem = (value) => {
|
|
377
|
+
const base = basename(value).toLowerCase();
|
|
378
|
+
return win ? base.replace(/\.[^.]+$/, '') : base;
|
|
379
|
+
};
|
|
380
|
+
const self = stem(bin);
|
|
381
|
+
const found = [];
|
|
382
|
+
for (const candidate of LOCAL_CLI_ALTERNATIVES) {
|
|
383
|
+
if (candidate === self)
|
|
384
|
+
continue;
|
|
385
|
+
const dir = dirs.find((item) => exts.some((ext) => {
|
|
386
|
+
try {
|
|
387
|
+
accessSync(join(item, candidate + ext), constants.X_OK);
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}));
|
|
394
|
+
if (dir !== undefined)
|
|
395
|
+
found.push(candidate);
|
|
396
|
+
}
|
|
397
|
+
return found;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* 只读探测失败 → 用户能照着做的一句话。按**成因**分档(而不是原样甩 `exit status 1`):
|
|
401
|
+
* CLI 不在(装 Docker / 改 `dockerBin`)、超时(daemon 卡死 / context 指错)、
|
|
402
|
+
* daemon 不可达、socket 无权、其它(原样透出)。
|
|
403
|
+
*
|
|
404
|
+
* `probe()` 的两条失败路径形状不同:`runLocal` 对 ENOENT 是**抛错**(`无法执行 docker:spawn docker ENOENT`),
|
|
405
|
+
* 而 daemon 连不上是 docker CLI 自己以非零退出 + stderr 文案返回;两条都要认得出。
|
|
406
|
+
*/
|
|
407
|
+
export function describeLocalProbeFailure(raw, bin = 'docker', hint = {}) {
|
|
408
|
+
const text = raw.trim();
|
|
409
|
+
if (isLocalCliMissing(text)) {
|
|
410
|
+
const head = `宿主上找不到 docker CLI(${bin}):请先安装 Docker,或在设置卡片把「docker CLI」改成正确的可执行文件路径`;
|
|
411
|
+
// 有候选就点出来——只说「请先安装 Docker」会让 podman 用户以为要装一整套 Docker。
|
|
412
|
+
// 同时写明**面板不会替你改**:这是提示,不是自动切换(见 findAlternativeLocalCli)。
|
|
413
|
+
return hint.alternativeCli === undefined || hint.alternativeCli === ''
|
|
414
|
+
? head
|
|
415
|
+
: `${head}。这台机器上装了 ${hint.alternativeCli}:把「docker CLI」填成 ${hint.alternativeCli} 就能用(面板不会替你改)`;
|
|
416
|
+
}
|
|
417
|
+
/*
|
|
418
|
+
* 超时单独一档(D159):它和「daemon 没起」的可修动作完全不同,也不能落进下面
|
|
419
|
+
* 那句「退出码 null」。判据来自 `ProbeResult.timedOut` 而不是解析文案——CLI 的
|
|
420
|
+
* 超时输出里根本没有可认的字样。
|
|
421
|
+
*/
|
|
422
|
+
if (hint.timedOut === true) {
|
|
423
|
+
return `${bin} version 15 秒没返回:daemon 可能卡死,或 docker context 指向了连不上的远端(用 docker context ls 看当前指向;Docker Desktop 卡死时重启它)`;
|
|
424
|
+
}
|
|
425
|
+
if (/ECONNREFUSED|ENOENT.*\.sock|\.sock.*(no such file|不是目录|No such)/i.test(text)) {
|
|
426
|
+
return `docker CLI 可用,但连不上 docker daemon:请确认 daemon 已启动(Linux: systemctl start docker;macOS/Windows: 启动 Docker Desktop)`;
|
|
427
|
+
}
|
|
428
|
+
if (/permission denied/i.test(text)) {
|
|
429
|
+
return `docker CLI 可用,但当前账号无权访问 docker socket:把运行宿主的账号加入 docker 组,或改用允许的 socket`;
|
|
430
|
+
}
|
|
431
|
+
if (/Is the docker daemon running|Cannot connect to the Docker daemon|error during connect/i.test(text)) {
|
|
432
|
+
return `docker CLI 可用,但连不上 docker daemon:请确认 daemon 已启动(Linux: systemctl start docker;macOS/Windows: 启动 Docker Desktop)`;
|
|
433
|
+
}
|
|
434
|
+
return `docker daemon 探测失败:${text === '' ? 'docker version 没有返回任何信息' : text}`;
|
|
435
|
+
}
|
|
282
436
|
/**
|
|
283
437
|
* 清洗一份 hostKeys 输入(settings 存储 / 热更新 / 种子复制共用)。
|
|
284
438
|
*
|
|
@@ -639,7 +793,7 @@ const plugin = definePlugin({
|
|
|
639
793
|
* 刻意放在 `enabled` 判定**之前**:授权是宿主级的、与插件这次是否启用无关;插件这次是禁用态时
|
|
640
794
|
* 反而更该说一句(「盘上有授权,但插件没开」与「点了没反应」是两回事)。
|
|
641
795
|
*/
|
|
642
|
-
auditLoadedGrants(grantStore, [CAP_MUTATIONS.env, CAP_EXEC.env],
|
|
796
|
+
auditLoadedGrants(grantStore, [CAP_MUTATIONS.env, CAP_EXEC.env], '[dsh-docker]');
|
|
643
797
|
// volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
|
|
644
798
|
const config = plainConfig((rawConfig ?? {}));
|
|
645
799
|
/*
|
|
@@ -667,6 +821,7 @@ const plugin = definePlugin({
|
|
|
667
821
|
};
|
|
668
822
|
/* ---------- 主机指纹(TOFU,本插件自持;tty 记录作种子) ---------- */
|
|
669
823
|
let settingsScope;
|
|
824
|
+
let settingsForms;
|
|
670
825
|
/**
|
|
671
826
|
* 只读他人 entry(如 tty 的连接簿):旧 `settings.get(ns)` 在新宿主已不存在,
|
|
672
827
|
* 改走 `settings.describe()` 的 entry 查找(kit 的 readSettingsEntry)。settings
|
|
@@ -761,13 +916,19 @@ const plugin = definePlugin({
|
|
|
761
916
|
}
|
|
762
917
|
return resolveTarget(target, readTtyBooks(settingsApi));
|
|
763
918
|
};
|
|
764
|
-
/**
|
|
765
|
-
|
|
919
|
+
/**
|
|
920
|
+
* 取某个目标上的 DockerApi(Runner 每次新建,连接由 RemoteExec 池化)。
|
|
921
|
+
*
|
|
922
|
+
* `signal`(D161)只有**跨目标聚合**会传:它把「这一格的预算到点了」串到该目标
|
|
923
|
+
* **底层每一条命令**上(`createRunner` 绑到 Runner 上,见那边的注释)。其余调用点
|
|
924
|
+
* 不传,行为与之前完全一致。
|
|
925
|
+
*/
|
|
926
|
+
const apiFor = (name, signal) => {
|
|
766
927
|
const { resolved, error } = resolveByName(name);
|
|
767
928
|
if (resolved === undefined)
|
|
768
929
|
return { error };
|
|
769
930
|
try {
|
|
770
|
-
const runner = createRunner({ target: resolved, remote, logger });
|
|
931
|
+
const runner = createRunner({ target: resolved, remote, logger, ...(signal === undefined ? {} : { signal }) });
|
|
771
932
|
return { api: new DockerApi(runner, live.dockerBin, { timeoutMs: 30_000, maxBytes: live.maxOutputKb * 1024 }), resolved };
|
|
772
933
|
}
|
|
773
934
|
catch (error_) {
|
|
@@ -799,18 +960,185 @@ const plugin = definePlugin({
|
|
|
799
960
|
return { name: fallback };
|
|
800
961
|
const list = targetsNow();
|
|
801
962
|
if (list.length === 0)
|
|
802
|
-
return { error: '尚未配置任何 Docker
|
|
963
|
+
return { error: '尚未配置任何 Docker 目标:可调 docker_connect_local 一键加上本机目标,或到 插件配置 → Docker 容器面板 添加' };
|
|
803
964
|
return { error: 'target 必填(已配置多个目标:' + list.map((item) => item.name).join('、') + ')' };
|
|
804
965
|
};
|
|
966
|
+
/* ---------- 一键连接本机(面板按钮 / HTTP 路由 / agent 工具共用这一份实现) ---------- */
|
|
967
|
+
/**
|
|
968
|
+
* 探测失败 → 给用户的一句话(这次探测的 bin 以**配置里的 `dockerBin`** 为准)。
|
|
969
|
+
*
|
|
970
|
+
* 只有「CLI 缺失」这一档才去 PATH 里找候选:那要 accessSync 若干次,其余档位全是
|
|
971
|
+
* 纯文本判断,不该无谓地跑文件系统。
|
|
972
|
+
*/
|
|
973
|
+
const localProbeReason = (raw, timedOut = false) => {
|
|
974
|
+
const alternative = isLocalCliMissing(raw) ? findAlternativeLocalCli(live.dockerBin)[0] : undefined;
|
|
975
|
+
return describeLocalProbeFailure(raw, live.dockerBin, {
|
|
976
|
+
timedOut,
|
|
977
|
+
...(alternative === undefined ? {} : { alternativeCli: alternative }),
|
|
978
|
+
});
|
|
979
|
+
};
|
|
980
|
+
/**
|
|
981
|
+
* 显式加一个本机目标并选中它,然后把只读探测结果一并回报。
|
|
982
|
+
*
|
|
983
|
+
* 四条不变量(缺任何一条都会变成另一种东西):
|
|
984
|
+
* - **幂等**:已有 `kind=local` 就复用它(`created:false`),绝不重复创建;重复点、并发点都落到同一条。
|
|
985
|
+
* - **不改别人的**:SSH / 自定义目标原样保留,`targets` 顺序也只追加(`[...existing, 新建]`)——
|
|
986
|
+
* 不排序、不重写已存在的条目(重写会丢掉 `mergeTargetSecrets` 才补得回的密码)。
|
|
987
|
+
* - **没有隐式默认目标**:只有本函数被显式调用才动 `targets`;空数组 / `clearTargets` 的既有语义一字不改。
|
|
988
|
+
* - **落盘成功才算数**:settings 不可用或保存失败一律**明确报错且不改内存配置**(no-op),
|
|
989
|
+
* 绝不出现「界面显示已加上了、刷新就没了」。
|
|
990
|
+
*
|
|
991
|
+
* 权限:这条路径只写**本插件自己的配置**(加一个指向宿主自身的本机目标)+ 只读探测,
|
|
992
|
+
* 与服务端 root 等价的那两档能力(`allowMutations` / `exec`)无关,因此不要求任何授权;
|
|
993
|
+
* 也绝不自动装 Docker、起 daemon、切 docker context 或改 env。
|
|
994
|
+
*/
|
|
995
|
+
const connectLocalOnce = async () => {
|
|
996
|
+
if (!live.enabled)
|
|
997
|
+
return { ok: false, code: 403, error: 'Docker 插件已禁用' };
|
|
998
|
+
const existing = targetsNow();
|
|
999
|
+
const found = findLocalTargetName(existing);
|
|
1000
|
+
// Reusing an existing local target only probes it; it does not need a settings write.
|
|
1001
|
+
if (found === undefined && settingsScope === undefined) {
|
|
1002
|
+
return { ok: false, code: 503, error: '设置存储不可用(宿主未提供 settings 服务),无法保存本机目标;请稍后重试' };
|
|
1003
|
+
}
|
|
1004
|
+
const name = found ?? nextLocalTargetName(existing.map((item) => item.name));
|
|
1005
|
+
if (name === undefined) {
|
|
1006
|
+
return { ok: false, code: 400, error: `本机目标名冲突:已有 1000 个 local* 目标,请先删掉一些再试` };
|
|
1007
|
+
}
|
|
1008
|
+
let created = false;
|
|
1009
|
+
if (found === undefined) {
|
|
1010
|
+
/* 新条目只写展示名与 kind:其余字段交给 normalizeConfig / sanitizeTargets 的默认值(单一来源)。 */
|
|
1011
|
+
const scope = settingsScope;
|
|
1012
|
+
let entry;
|
|
1013
|
+
try {
|
|
1014
|
+
entry = settingsForms?.describe().find((item) => item.ns === scope.ns);
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
return { ok: false, code: 503, error: '无法读取当前目标配置,请稍后重试' };
|
|
1018
|
+
}
|
|
1019
|
+
if (entry === undefined)
|
|
1020
|
+
return { ok: false, code: 503, error: '当前设置条目不可用,请稍后重试' };
|
|
1021
|
+
const rawTargets = entry.value?.targets;
|
|
1022
|
+
const nextTargets = [...(Array.isArray(rawTargets) ? rawTargets : existing), { name, kind: 'local' }];
|
|
1023
|
+
try {
|
|
1024
|
+
await settingsForms.update(scope.ns, { targets: nextTargets }, entry.revision);
|
|
1025
|
+
}
|
|
1026
|
+
catch (error) {
|
|
1027
|
+
return { ok: false, code: 500, error: '保存本机目标失败:' + (error instanceof Error ? error.message : String(error)) };
|
|
1028
|
+
}
|
|
1029
|
+
created = true;
|
|
1030
|
+
/*
|
|
1031
|
+
* `scope.update` 之后必须自己应用一次:真实 loader 会发 `loader/volatile-update` 触发
|
|
1032
|
+
* applySection,但那个事件是**异步**的,单测 / 老宿主还可能完全不发。这里同步应用是幂等的
|
|
1033
|
+
* (applySection 用同一份 settings 值再归一化一次),而**不应用**的后果是「刚加上的目标
|
|
1034
|
+
* 在探测那一步还解析不到」。
|
|
1035
|
+
*/
|
|
1036
|
+
try {
|
|
1037
|
+
applySection({ targets: nextTargets });
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
return {
|
|
1041
|
+
ok: false,
|
|
1042
|
+
code: 500,
|
|
1043
|
+
error: '本机目标已保存但应用失败:' + (error instanceof Error ? error.message : String(error)),
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// 探测失败一律降级为 message(见 ConnectLocalResult 的注释),不是这次操作的失败
|
|
1048
|
+
const built = apiFor(name);
|
|
1049
|
+
if (built.api === undefined) {
|
|
1050
|
+
return {
|
|
1051
|
+
ok: true,
|
|
1052
|
+
result: {
|
|
1053
|
+
name,
|
|
1054
|
+
kind: 'local',
|
|
1055
|
+
created,
|
|
1056
|
+
saved: true,
|
|
1057
|
+
reachable: false,
|
|
1058
|
+
message: `已${created ? '添加并选中' : '选中'}本机目标「${name}」,但无法构造执行通道:${built.error ?? '未知错误'}`,
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
let reachable = false;
|
|
1063
|
+
let serverVersion;
|
|
1064
|
+
let reason = '';
|
|
1065
|
+
try {
|
|
1066
|
+
const probe = await built.api.probe();
|
|
1067
|
+
reachable = probe.ok;
|
|
1068
|
+
if (probe.serverVersion !== null)
|
|
1069
|
+
serverVersion = probe.serverVersion;
|
|
1070
|
+
if (!probe.ok)
|
|
1071
|
+
reason = localProbeReason(probe.error ?? '', probe.timedOut);
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
reason = localProbeReason(error instanceof Error ? error.message : String(error));
|
|
1075
|
+
}
|
|
1076
|
+
const head = `已${created ? '添加并选中' : '选中'}本机目标「${name}」`;
|
|
1077
|
+
return {
|
|
1078
|
+
ok: true,
|
|
1079
|
+
result: {
|
|
1080
|
+
name,
|
|
1081
|
+
kind: 'local',
|
|
1082
|
+
created,
|
|
1083
|
+
saved: true,
|
|
1084
|
+
reachable,
|
|
1085
|
+
...(serverVersion === undefined ? {} : { serverVersion }),
|
|
1086
|
+
message: reachable
|
|
1087
|
+
? `${head}:docker daemon 可达${serverVersion === undefined ? '' : `(${serverVersion})`}`
|
|
1088
|
+
// 连接失败必须说清**为什么**:daemon 没起 / CLI 缺失 / socket 权限,而不是只报「没有容器」
|
|
1089
|
+
: `${head},但本机 docker 不可用——${reason === '' ? 'docker version 未返回可用信息' : reason}`,
|
|
1090
|
+
},
|
|
1091
|
+
};
|
|
1092
|
+
};
|
|
1093
|
+
// Serialize this read-modify-write across HTTP and tool callers. Rejections never poison the queue.
|
|
1094
|
+
let localConnectQueue = Promise.resolve();
|
|
1095
|
+
const connectLocal = () => {
|
|
1096
|
+
const pending = localConnectQueue.then(connectLocalOnce);
|
|
1097
|
+
localConnectQueue = pending.catch(() => { });
|
|
1098
|
+
return pending;
|
|
1099
|
+
};
|
|
805
1100
|
/**
|
|
806
1101
|
* 跨目标聚合(0.15.0):所有目标的容器 / 需关注列表在面板与 agent 工具里共用同
|
|
807
1102
|
* 一套逻辑。三个必须的性质:
|
|
808
1103
|
* - **并发上限**:ssh exec 扇出太多会互相挤(远端 sshd MaxStartups / 本机 fd);
|
|
809
1104
|
* - **单目标超时**:一台网络不通不能把整个聚合页拖住;
|
|
810
1105
|
* - **错误隔离**:失败的组带上 error 照常返回,其余目标的结果照常可用。
|
|
1106
|
+
*
|
|
1107
|
+
* 第四个性质是 **D161 补上的**:单个目标超时的那一刻,**它底下正在跑的命令必须真的被
|
|
1108
|
+
* 停掉**。旧写法只 `Promise.race` 出一个 `ok:false`,底层那条 SSH channel 继续跑完——
|
|
1109
|
+
* 而它占着的正是 `MaxSessions` 的 session 槽(sshd 在子进程活着时不释放,见 D150)。
|
|
811
1110
|
*/
|
|
812
1111
|
const AGG_CONCURRENCY = 4;
|
|
813
|
-
|
|
1112
|
+
/**
|
|
1113
|
+
* 单目标上**每一条**命令的内层预算(30s,DockerApi 构造时传下去的那一个)。
|
|
1114
|
+
*
|
|
1115
|
+
* 单独取个名字是为了让「外层兜底」与「内层真相」共用一个来源:D161 之前这里是
|
|
1116
|
+
* `apiFor` 里的裸字面量 `30_000`,而外层是另一个裸字面量 `45_000`——两个数字各写各的,
|
|
1117
|
+
* 谁也不知道它们的关系,于是 45s 那个到底会不会打断一条健康命令,没人说得清。
|
|
1118
|
+
*/
|
|
1119
|
+
const AGG_CMD_TIMEOUT_MS = 30_000;
|
|
1120
|
+
/**
|
|
1121
|
+
* 单个目标那一格的**兜底**预算(D161)。
|
|
1122
|
+
*
|
|
1123
|
+
* 取舍(两种做法里选了后者):
|
|
1124
|
+
* - ①「把外层提到大于单目标最坏序列耗时」——`attention()` 是 `listContainers` +
|
|
1125
|
+
* 最多约 9 批 `inspect` 的**串行**序列(`ATTENTION_INSPECT_CAP 300 + 补捞 50`,
|
|
1126
|
+
* 每批 ≤ 40),最坏 10 × 30s ≈ 300s。真按这个上界设,总览页等一台卡住的机器要
|
|
1127
|
+
* 等五分钟,等于没有预算;
|
|
1128
|
+
* - ②**内层超时是唯一真相源,外层只做兜底** ← 选这个。30s/命令已经给每条命令兜了底,
|
|
1129
|
+
* 外层存在的意义只是「内层没兜住时(连接获取 / 闸门排队 / 某个实现没遵守
|
|
1130
|
+
* timeoutMs)别把整页拖死」。所以它取 3 倍单条预算(90s):大于 `attention` 的
|
|
1131
|
+
* 典型两段(ps + 首批 inspect ≈ 60s),不会被健康命令序列撞上;又给异常留了一档
|
|
1132
|
+
* 天花板。到点即 `abort()`——**这是这次修复的实质**,配置数字只是顺带对齐。
|
|
1133
|
+
*
|
|
1134
|
+
* `DSH_DOCKER_AGG_TIMEOUT_MS` 可覆盖,**只为测试与排障**(同 `DSH_DOCKER_SHORT_CHANNELS`
|
|
1135
|
+
* 的口径):90s 这条路径没法在单测里等,而「超时后底层有没有真的被取消」恰恰是本轮
|
|
1136
|
+
* 的验收点——没有这个口子就只能测到「返回了 ok:false」,那正是修复前就恒绿的那条。
|
|
1137
|
+
*/
|
|
1138
|
+
function aggTimeoutMs() {
|
|
1139
|
+
const raw = Number(process.env.DSH_DOCKER_AGG_TIMEOUT_MS);
|
|
1140
|
+
return Number.isFinite(raw) && raw > 0 ? raw : AGG_CMD_TIMEOUT_MS * 3;
|
|
1141
|
+
}
|
|
814
1142
|
async function mapLimit(items, limit, run) {
|
|
815
1143
|
const results = new Array(items.length);
|
|
816
1144
|
let cursor = 0;
|
|
@@ -829,18 +1157,35 @@ const plugin = definePlugin({
|
|
|
829
1157
|
/** 在所有(或指定)目标上跑同一件事,返回按目标分组的「部分成功」结果。 */
|
|
830
1158
|
const aggregateAcrossTargets = async (names, run) => {
|
|
831
1159
|
return await mapLimit(names, AGG_CONCURRENCY, async (name) => {
|
|
832
|
-
|
|
1160
|
+
/*
|
|
1161
|
+
* 控制器必须建在 `run()` **之前**(D161):Runner 是在 `apiFor` 里构造的,signal
|
|
1162
|
+
* 当场绑到 Runner 上(见 createRunner)。先跑再建的话,abort 时底层根本不知道
|
|
1163
|
+
* 自己被取消——那正是修复前的行为。
|
|
1164
|
+
*/
|
|
1165
|
+
const controller = new AbortController();
|
|
1166
|
+
const budget = aggTimeoutMs();
|
|
1167
|
+
const built = apiFor(name, controller.signal);
|
|
833
1168
|
const resolved = built.resolved;
|
|
834
1169
|
const label = resolved === undefined
|
|
835
1170
|
? name
|
|
836
1171
|
: (resolved.kind === 'local' ? '本机' : sshTarget(resolved.spec));
|
|
837
|
-
if (built.api === undefined)
|
|
1172
|
+
if (built.api === undefined) {
|
|
1173
|
+
controller.abort();
|
|
838
1174
|
return { target: name, label, ok: false, error: built.error ?? '无法构造执行通道' };
|
|
1175
|
+
}
|
|
839
1176
|
const api = built.api;
|
|
840
1177
|
let timer = null;
|
|
841
1178
|
try {
|
|
842
1179
|
const timeout = new Promise((_resolve, reject) => {
|
|
843
|
-
timer = setTimeout(() =>
|
|
1180
|
+
timer = setTimeout(() => {
|
|
1181
|
+
/*
|
|
1182
|
+
* 顺序是**先取消、后拒绝**(D161):反过来的话调用方在 reject 的那一刻就拿到
|
|
1183
|
+
* `ok:false` 返回了,而底层(本地子进程 / SSH channel)还在跑——用户看到的是
|
|
1184
|
+
* 「这一格失败了」,实际那条 inspect 还占着远端的 session 槽,后续短命令被拒。
|
|
1185
|
+
*/
|
|
1186
|
+
controller.abort();
|
|
1187
|
+
reject(new Error(`聚合超时(>${String(budget / 1000)}s)`));
|
|
1188
|
+
}, budget);
|
|
844
1189
|
timer.unref?.();
|
|
845
1190
|
});
|
|
846
1191
|
const data = await Promise.race([run(api, name), timeout]);
|
|
@@ -852,6 +1197,12 @@ const plugin = definePlugin({
|
|
|
852
1197
|
finally {
|
|
853
1198
|
if (timer !== null)
|
|
854
1199
|
clearTimeout(timer);
|
|
1200
|
+
/*
|
|
1201
|
+
* race 另一侧赢(命令正常跑完 / 抛错)时也要兜底取消:`abort()` 是幂等的,命令
|
|
1202
|
+
* 已 settle 时它是**无害的空操作**(监听器在收尾时已摘掉),但漏了这一句就会留下
|
|
1203
|
+
* 「signal 永不 abort、挂在它上面的闭包链不放」的尾巴。
|
|
1204
|
+
*/
|
|
1205
|
+
controller.abort();
|
|
855
1206
|
}
|
|
856
1207
|
});
|
|
857
1208
|
};
|
|
@@ -1548,6 +1899,41 @@ const plugin = definePlugin({
|
|
|
1548
1899
|
return { targets: rows };
|
|
1549
1900
|
},
|
|
1550
1901
|
}));
|
|
1902
|
+
add('docker_connect_local', defineTool({
|
|
1903
|
+
name: 'docker_connect_local',
|
|
1904
|
+
// 只读之外的唯一副作用是**写本插件自己的配置**,没有别的写路径;并发调用被 connectLocal 的幂等语义收敛
|
|
1905
|
+
isConcurrencySafe: () => true,
|
|
1906
|
+
description: '添加或复用本机 Docker 目标并探测 daemon,返回目标名、是否新建和连接状态及失败原因。保留已有 SSH 配置;不启动或安装 Docker,不改变 context 或能力授权。随后将返回的 name 作为 docker_ps 等工具的 target。',
|
|
1907
|
+
parameters: {},
|
|
1908
|
+
output: {
|
|
1909
|
+
schema: {
|
|
1910
|
+
type: 'object',
|
|
1911
|
+
additionalProperties: false,
|
|
1912
|
+
properties: {
|
|
1913
|
+
name: { type: 'string', required: true },
|
|
1914
|
+
kind: { type: 'string', required: true },
|
|
1915
|
+
created: { type: 'boolean', required: true },
|
|
1916
|
+
saved: { type: 'boolean', required: true },
|
|
1917
|
+
reachable: { type: 'boolean', required: true },
|
|
1918
|
+
message: { type: 'string', required: true },
|
|
1919
|
+
serverVersion: { type: 'string' },
|
|
1920
|
+
},
|
|
1921
|
+
},
|
|
1922
|
+
render: (_args, value) => {
|
|
1923
|
+
const row = value;
|
|
1924
|
+
return [{
|
|
1925
|
+
type: 'text',
|
|
1926
|
+
text: `${row.message}\n后续查询请传 target: ${JSON.stringify(row.name)}`,
|
|
1927
|
+
}];
|
|
1928
|
+
},
|
|
1929
|
+
},
|
|
1930
|
+
async execute() {
|
|
1931
|
+
const outcome = await connectLocal();
|
|
1932
|
+
if (!outcome.ok)
|
|
1933
|
+
throw new Error(outcome.error);
|
|
1934
|
+
return outcome.result;
|
|
1935
|
+
},
|
|
1936
|
+
}));
|
|
1551
1937
|
add('docker_ps', defineTool({
|
|
1552
1938
|
name: 'docker_ps',
|
|
1553
1939
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
@@ -2287,6 +2673,7 @@ const plugin = definePlugin({
|
|
|
2287
2673
|
throw new Error(picked.error ?? '无效的 target');
|
|
2288
2674
|
if (typeof input.id !== 'string')
|
|
2289
2675
|
throw new Error('id 必填');
|
|
2676
|
+
const id = input.id;
|
|
2290
2677
|
const action = input.action;
|
|
2291
2678
|
if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
|
|
2292
2679
|
throw new Error('action 必须是 start / stop / restart / remove');
|
|
@@ -2294,7 +2681,7 @@ const plugin = definePlugin({
|
|
|
2294
2681
|
const { api } = apiFor(picked.name);
|
|
2295
2682
|
if (api === undefined)
|
|
2296
2683
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
2297
|
-
const result = await api.action({ action, id
|
|
2684
|
+
const result = await audited({ capability: 'allowMutations', source: 'tool', action: `container.${action}`, target: picked.name, ref: id }, () => api.action({ action, id }));
|
|
2298
2685
|
return { target: picked.name, id: result.id, action: result.action, message: result.message };
|
|
2299
2686
|
},
|
|
2300
2687
|
}));
|
|
@@ -2329,10 +2716,11 @@ const plugin = definePlugin({
|
|
|
2329
2716
|
throw new Error(picked.error ?? '无效的 target');
|
|
2330
2717
|
if (typeof input.ref !== 'string' || input.ref.trim() === '')
|
|
2331
2718
|
throw new Error('ref 必填');
|
|
2719
|
+
const ref = input.ref;
|
|
2332
2720
|
const { api } = apiFor(picked.name);
|
|
2333
2721
|
if (api === undefined)
|
|
2334
2722
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
2335
|
-
const result = await api.imageRemove(
|
|
2723
|
+
const result = await audited({ capability: 'allowMutations', source: 'tool', action: 'image.remove', target: picked.name, ref }, () => api.imageRemove(ref));
|
|
2336
2724
|
return { target: picked.name, ref: result.ref, message: result.message };
|
|
2337
2725
|
},
|
|
2338
2726
|
}));
|
|
@@ -2364,7 +2752,7 @@ const plugin = definePlugin({
|
|
|
2364
2752
|
const { api } = apiFor(picked.name);
|
|
2365
2753
|
if (api === undefined)
|
|
2366
2754
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
2367
|
-
const result = await api.imagePrune();
|
|
2755
|
+
const result = await audited({ capability: 'allowMutations', source: 'tool', action: 'image.prune', target: picked.name }, () => api.imagePrune());
|
|
2368
2756
|
return { target: picked.name, message: result.message };
|
|
2369
2757
|
},
|
|
2370
2758
|
}));
|
|
@@ -2403,11 +2791,12 @@ const plugin = definePlugin({
|
|
|
2403
2791
|
throw new Error(picked.error ?? '无效的 target');
|
|
2404
2792
|
if (typeof input.ref !== 'string' || input.ref.trim() === '')
|
|
2405
2793
|
throw new Error('ref 必填');
|
|
2794
|
+
const ref = input.ref;
|
|
2406
2795
|
const { api } = apiFor(picked.name);
|
|
2407
2796
|
if (api === undefined)
|
|
2408
2797
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
2409
2798
|
const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : 600;
|
|
2410
|
-
const result = await api.pull(
|
|
2799
|
+
const result = await audited({ capability: 'allowMutations', source: 'tool', action: 'image.pull', target: picked.name, ref }, () => api.pull(ref, timeoutSec * 1000), (row) => row.code === null ? undefined : `code=${String(row.code)}`);
|
|
2411
2800
|
return {
|
|
2412
2801
|
target: picked.name,
|
|
2413
2802
|
ref: result.ref,
|
|
@@ -2464,11 +2853,13 @@ const plugin = definePlugin({
|
|
|
2464
2853
|
throw new Error('id 必填');
|
|
2465
2854
|
if (typeof input.command !== 'string' || input.command.trim() === '')
|
|
2466
2855
|
throw new Error('command 必填');
|
|
2856
|
+
const id = input.id;
|
|
2857
|
+
const command = input.command;
|
|
2467
2858
|
const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : live.execTimeoutSec;
|
|
2468
2859
|
const { api } = apiFor(picked.name);
|
|
2469
2860
|
if (api === undefined)
|
|
2470
2861
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
2471
|
-
const result = await api.exec(
|
|
2862
|
+
const result = await audited({ capability: 'allowExec', source: 'tool', action: 'exec', target: picked.name, ref: id }, () => api.exec(id, command, timeoutSec * 1000), (row) => `code=${String(row.code)} cmd=${row.command}`);
|
|
2472
2863
|
return {
|
|
2473
2864
|
target: picked.name,
|
|
2474
2865
|
id: result.id,
|
|
@@ -2480,6 +2871,13 @@ const plugin = definePlugin({
|
|
|
2480
2871
|
},
|
|
2481
2872
|
}));
|
|
2482
2873
|
}
|
|
2874
|
+
// 档位闸与工具同生命周期:refreshTools 幂等重建时随 toolDisposers 一起撤下/重挂
|
|
2875
|
+
// (禁用热生效时工具与闸一起撤;机制见 kit 的 tier-gate.ts,方案见 docs/permission-tier-plan.md)。
|
|
2876
|
+
toolDisposers.push(attachTierGate(ctx, {
|
|
2877
|
+
pkg: 'docker',
|
|
2878
|
+
prefixes: DOCKER_TIER_PREFIXES,
|
|
2879
|
+
classify: (tool) => DOCKER_TIER_CLASS[tool],
|
|
2880
|
+
}));
|
|
2483
2881
|
if (failures.length > 0) {
|
|
2484
2882
|
console.warn(`[dsh-docker] ${String(failures.length)} 个 agent 工具注册失败(下次配置变更会重试):${failures.join(';')}`);
|
|
2485
2883
|
}
|
|
@@ -2842,6 +3240,8 @@ const plugin = definePlugin({
|
|
|
2842
3240
|
writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
|
|
2843
3241
|
return;
|
|
2844
3242
|
}
|
|
3243
|
+
// run 回调里 TS 不保留 picked.name 的属性窄化,先落局部 const
|
|
3244
|
+
const targetName = picked.name;
|
|
2845
3245
|
await openSseStream(res, {
|
|
2846
3246
|
reason: 'pull-exit',
|
|
2847
3247
|
// 同日志流:逐层进度是文本尾部流,积压时丢最旧而不是把整条流掐掉(D153)
|
|
@@ -2852,13 +3252,26 @@ const plugin = definePlugin({
|
|
|
2852
3252
|
const coalescer = createSseCoalescer({
|
|
2853
3253
|
emit: (channel, text) => sendEvent('line', channel === 'd' ? { d: text } : { e: text }),
|
|
2854
3254
|
});
|
|
3255
|
+
/*
|
|
3256
|
+
* 使用审计拆成 start / end 两行(capability-use 是逐次事件,不是逐行日志):
|
|
3257
|
+
* 开流记 start(宿主中途挂掉时至少「有人拉过这个镜像」还在),收尾记 end——
|
|
3258
|
+
* 被中止时 runner 以 code=null 落定,end 行省略 code;抛错则 ok=false + detail。
|
|
3259
|
+
*/
|
|
3260
|
+
const useFields = { capability: 'allowMutations', source: 'http', action: 'image.pull', target: targetName, ref: safeRef };
|
|
3261
|
+
const startedAt = Date.now();
|
|
3262
|
+
auditCapabilityUse({ ...useFields, event: 'start' });
|
|
2855
3263
|
try {
|
|
2856
3264
|
const result = await api.pullStream(safeRef, {
|
|
2857
3265
|
onStdout: (chunk) => coalescer.push('d', chunk),
|
|
2858
3266
|
onStderr: (chunk) => coalescer.push('e', chunk),
|
|
2859
3267
|
}, signal);
|
|
3268
|
+
auditCapabilityUse({ ...useFields, event: 'end', ok: true, durationMs: Date.now() - startedAt, reason: 'pull-exit', ...(result.code === null ? {} : { code: result.code }) });
|
|
2860
3269
|
return result.code;
|
|
2861
3270
|
}
|
|
3271
|
+
catch (error) {
|
|
3272
|
+
auditCapabilityUse({ ...useFields, event: 'end', ok: false, durationMs: Date.now() - startedAt, detail: error instanceof Error ? error.message : String(error) });
|
|
3273
|
+
throw error;
|
|
3274
|
+
}
|
|
2862
3275
|
finally {
|
|
2863
3276
|
coalescer.flush();
|
|
2864
3277
|
coalescer.dispose();
|
|
@@ -3039,6 +3452,29 @@ const plugin = definePlugin({
|
|
|
3039
3452
|
writeJson(res, 200, { ok: true, config: snapshot(), ...(warning === undefined ? {} : { warning }) });
|
|
3040
3453
|
return;
|
|
3041
3454
|
}
|
|
3455
|
+
/*
|
|
3456
|
+
* 一键连接本机:把「加一个 local 目标」从「先开设置卡片、手填名字、保存、再切回来」压缩成一下。
|
|
3457
|
+
* 为什么是显式按钮而不是隐式默认目标:空 `targets` 的语义(= 用户没配、面板给引导)不能被悄悄改掉。
|
|
3458
|
+
* 只读探测的结果作为 `message` 回传(含「daemon 未启动 / CLI 缺失」的成因),
|
|
3459
|
+
* 客户端拿它直接给提示,不必再单独发一次 /probe 往返。
|
|
3460
|
+
*/
|
|
3461
|
+
if (sub === '/connect-local') {
|
|
3462
|
+
if (req.method !== 'POST') {
|
|
3463
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
if (!hasSameOriginProof(req)) {
|
|
3467
|
+
writeJson(res, 403, { error: originProofHint(req) });
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
const outcome = await connectLocal();
|
|
3471
|
+
if (!outcome.ok) {
|
|
3472
|
+
writeJson(res, outcome.code, { error: outcome.error });
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
writeJson(res, 200, { ok: true, result: outcome.result, config: snapshot() });
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3042
3478
|
if (sub === '/targets') {
|
|
3043
3479
|
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
3044
3480
|
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
@@ -3270,7 +3706,10 @@ const plugin = definePlugin({
|
|
|
3270
3706
|
writeJson(res, 400, { error: 'ref 必填' });
|
|
3271
3707
|
return;
|
|
3272
3708
|
}
|
|
3273
|
-
|
|
3709
|
+
// 能力闸之后包审计(「使用 = 过了闸」;403 不记)——下同;
|
|
3710
|
+
// ref 先落局部 const:回调里 TS 不保留属性窄化
|
|
3711
|
+
const ref = body.ref;
|
|
3712
|
+
writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'image.remove', target: picked.name, ref }, () => api.imageRemove(ref)) });
|
|
3274
3713
|
return;
|
|
3275
3714
|
}
|
|
3276
3715
|
case '/images/prune': {
|
|
@@ -3278,7 +3717,7 @@ const plugin = definePlugin({
|
|
|
3278
3717
|
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3279
3718
|
return;
|
|
3280
3719
|
}
|
|
3281
|
-
writeJson(res, 200, { ok: true, result: await api.imagePrune() });
|
|
3720
|
+
writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'image.prune', target: picked.name }, () => api.imagePrune()) });
|
|
3282
3721
|
return;
|
|
3283
3722
|
}
|
|
3284
3723
|
case '/networks': {
|
|
@@ -3302,7 +3741,8 @@ const plugin = definePlugin({
|
|
|
3302
3741
|
writeJson(res, 400, { error: 'name 必填' });
|
|
3303
3742
|
return;
|
|
3304
3743
|
}
|
|
3305
|
-
|
|
3744
|
+
const name = body.name;
|
|
3745
|
+
writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'network.remove', target: picked.name, ref: name }, () => api.networkRemove(name)) });
|
|
3306
3746
|
return;
|
|
3307
3747
|
}
|
|
3308
3748
|
case '/networks/prune': {
|
|
@@ -3310,7 +3750,7 @@ const plugin = definePlugin({
|
|
|
3310
3750
|
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3311
3751
|
return;
|
|
3312
3752
|
}
|
|
3313
|
-
writeJson(res, 200, { ok: true, result: await api.networkPrune() });
|
|
3753
|
+
writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'network.prune', target: picked.name }, () => api.networkPrune()) });
|
|
3314
3754
|
return;
|
|
3315
3755
|
}
|
|
3316
3756
|
case '/volumes': {
|
|
@@ -3334,7 +3774,8 @@ const plugin = definePlugin({
|
|
|
3334
3774
|
writeJson(res, 400, { error: 'name 必填' });
|
|
3335
3775
|
return;
|
|
3336
3776
|
}
|
|
3337
|
-
|
|
3777
|
+
const name = body.name;
|
|
3778
|
+
writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'volume.remove', target: picked.name, ref: name }, () => api.volumeRemove(name)) });
|
|
3338
3779
|
return;
|
|
3339
3780
|
}
|
|
3340
3781
|
case '/volumes/prune': {
|
|
@@ -3342,7 +3783,7 @@ const plugin = definePlugin({
|
|
|
3342
3783
|
writeJson(res, 403, { error: mutationsOffMessage() });
|
|
3343
3784
|
return;
|
|
3344
3785
|
}
|
|
3345
|
-
writeJson(res, 200, { ok: true, result: await api.volumePrune() });
|
|
3786
|
+
writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: 'volume.prune', target: picked.name }, () => api.volumePrune()) });
|
|
3346
3787
|
return;
|
|
3347
3788
|
}
|
|
3348
3789
|
case '/action': {
|
|
@@ -3359,7 +3800,8 @@ const plugin = definePlugin({
|
|
|
3359
3800
|
writeJson(res, 400, { error: 'action 必须是 start / stop / restart / remove' });
|
|
3360
3801
|
return;
|
|
3361
3802
|
}
|
|
3362
|
-
|
|
3803
|
+
const id = body.id;
|
|
3804
|
+
writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowMutations', source: 'http', action: `container.${action}`, target: picked.name, ref: id }, () => api.action({ action, id })) });
|
|
3363
3805
|
return;
|
|
3364
3806
|
}
|
|
3365
3807
|
case '/exec': {
|
|
@@ -3372,7 +3814,11 @@ const plugin = definePlugin({
|
|
|
3372
3814
|
return;
|
|
3373
3815
|
}
|
|
3374
3816
|
const timeoutSec = typeof body.timeoutSec === 'number' && Number.isInteger(body.timeoutSec) ? body.timeoutSec : live.execTimeoutSec;
|
|
3375
|
-
|
|
3817
|
+
const id = body.id;
|
|
3818
|
+
const command = body.command;
|
|
3819
|
+
// exec 的退出码是命令自己的结果(非 0 不是 docker 报错):ok 只随 api 成败;
|
|
3820
|
+
// 命令随行进日志(这是要写进 README 的代价)——截断与控制字符转义由 helper 统一做
|
|
3821
|
+
writeJson(res, 200, { ok: true, result: await audited({ capability: 'allowExec', source: 'http', action: 'exec', target: picked.name, ref: id }, () => api.exec(id, command, timeoutSec * 1000), (row) => `code=${String(row.code)} cmd=${row.command}`) });
|
|
3376
3822
|
return;
|
|
3377
3823
|
}
|
|
3378
3824
|
default: {
|
|
@@ -3408,6 +3854,7 @@ const plugin = definePlugin({
|
|
|
3408
3854
|
// 卡片是自定义页(plugins.row.config),别再让 DSH 为本 entry 自动生成一份。
|
|
3409
3855
|
const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
|
|
3410
3856
|
settingsScope = scope;
|
|
3857
|
+
settingsForms = settingsCtx.settings;
|
|
3411
3858
|
settingsApi = { get: (ns) => readSettingsEntry(settingsCtx, ns) };
|
|
3412
3859
|
// 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)。
|
|
3413
3860
|
// 走 applySection 而不是直接 normalizeConfig:能力开关的**配置值**要一起记下来(见
|
|
@@ -3425,6 +3872,7 @@ const plugin = definePlugin({
|
|
|
3425
3872
|
off();
|
|
3426
3873
|
offAutoPage();
|
|
3427
3874
|
settingsScope = undefined;
|
|
3875
|
+
settingsForms = undefined;
|
|
3428
3876
|
settingsApi = undefined;
|
|
3429
3877
|
};
|
|
3430
3878
|
}, 'dsh-docker: settings');
|