@hyzyn/dsh-docker 0.8.1 → 0.9.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { join } from 'node:path';
1
2
  import z from '@deepseek-ai/schemastery';
2
- import { definePlugin, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
3
+ import { definePlugin, hasSameOriginProof, isLoopbackRequestStrict, originProofHint, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
3
4
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
- import * as dns from 'node:dns';
5
5
  import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
6
- import { RemoteExec, setCredentialResolver, sshTarget } from './ssh-exec.js';
6
+ 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';
7
8
  const TARGET_SCHEMA = z.object({
8
9
  name: z.string().required(),
9
10
  kind: z.union([z.const('local'), z.const('ssh')]).default('local'),
@@ -81,6 +82,13 @@ const MUTATION_SUBROUTES = new Set([
81
82
  '/volumes/remove',
82
83
  '/volumes/prune',
83
84
  '/exec',
85
+ /*
86
+ * 就地提权三条(判定条件是**精确子路径** + POST,所以必须逐条列;只写 '/elevate' 的话
87
+ * /elevate/status 与 /elevate/revoke 就是裸奔的——跨站页面能撤销授权、能对着确认码试错)。
88
+ */
89
+ '/elevate',
90
+ '/elevate/status',
91
+ '/elevate/revoke',
84
92
  ]);
85
93
  /** 「这条错误来自目标侧」的标记(见 guardTargetFailures)。 */
86
94
  const TARGET_FAILURE = Symbol('dsh-docker.target-failure');
@@ -134,170 +142,12 @@ export function sseFrame(event, data) {
134
142
  }
135
143
  /** SSE 心跳间隔(毫秒):注释帧只保活,客户端 EventSource 会忽略。 */
136
144
  const SSE_HEARTBEAT_MS = 15_000;
137
- /** HTTP 路由的 loopback 信任围栏(与 tty / dsh-mcp 同思路)。 */
138
145
  /*
139
- * 环回地址判定(D31):接受 127/8 全段(BSD/Linux 惯例——整个 127.0.0.0/8 都是
140
- * 环回,此前只认 127.0.0.1 一个字面量)与 IPv6 等价形式(::1、::ffff: 映射)。
146
+ * 回环围栏与同源证明:**实现已收敛到 `@hyzyn/dsh-kit`**(2026-09-25,项目级 ROADMAP
147
+ * 第 1 项)。本包是那段加固档的来源,行为一字未改;D31 / D32 / D80 / D110 / D139 五条
148
+ * 的成因、时序约束与桌面版例外随实现一起搬到了 `packages/kit/src/http.ts`——**那边的
149
+ * 注释是唯一归宿**,这里只留指针(否则又是「三处各写一份必然漂」)。
141
150
  */
142
- function isLoopbackAddress(address) {
143
- if (address === undefined || address === '')
144
- return false;
145
- let text = address.toLowerCase();
146
- if (text.startsWith('::ffff:'))
147
- text = text.slice(7);
148
- if (text === '::1')
149
- return true;
150
- const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
151
- return v4 !== null && v4[1] === '127';
152
- }
153
- /** 别名 Host 解析结果的缓存与超时(D110):请求路径里的 DNS 不该每请求都打一次,也不该无限等。 */
154
- const HOST_LOOPBACK_TTL_MS = 60_000;
155
- const HOST_LOOPBACK_CACHE_MAX = 64;
156
- const HOST_LOOPBACK_TIMEOUT_MS = 500;
157
- const hostLoopbackCache = new Map();
158
- /**
159
- * 解析一个主机名是否指向本机(D110)。带 500ms 超时与 60s 的 LRU(别名部署下每个请求都要过一次)。
160
- * **失败与超时不缓存**:解析器恢复后要立刻生效,而不是把一次抖动钉 60 秒。
161
- */
162
- async function lookupHostLoopback(host) {
163
- const cached = hostLoopbackCache.get(host);
164
- if (cached !== undefined && Date.now() - cached.at <= HOST_LOOPBACK_TTL_MS)
165
- return cached.loopback;
166
- let timer = null;
167
- try {
168
- const records = await Promise.race([
169
- dns.promises.lookup(host, { all: true }),
170
- new Promise((_resolve, reject) => {
171
- timer = setTimeout(() => reject(new Error(`DNS 解析超时(>${String(HOST_LOOPBACK_TIMEOUT_MS)}ms)`)), HOST_LOOPBACK_TIMEOUT_MS);
172
- timer.unref?.();
173
- }),
174
- ]);
175
- const loopback = records.some((record) => isLoopbackAddress(record.address));
176
- if (hostLoopbackCache.size >= HOST_LOOPBACK_CACHE_MAX) {
177
- const oldest = hostLoopbackCache.keys().next().value;
178
- if (oldest !== undefined)
179
- hostLoopbackCache.delete(oldest);
180
- }
181
- hostLoopbackCache.set(host, { at: Date.now(), loopback });
182
- return loopback;
183
- }
184
- catch {
185
- return false;
186
- }
187
- finally {
188
- if (timer !== null)
189
- clearTimeout(timer);
190
- }
191
- }
192
- /**
193
- * Host 是否指向本机(D31):字面量环回直接判;主机名 / /etc/hosts 别名走一次带超时的
194
- * DNS 解析。判不出来就拒绝——围栏宁可误拦一个怪别名,不能放行一个能解析到公网的 Host。
195
- */
196
- async function hostResolvesToLoopback(hostname) {
197
- const host = hostname.toLowerCase().replace(/\.$/, '');
198
- if (host === 'localhost' || host.endsWith('.localhost') || isLoopbackAddress(host))
199
- return true;
200
- return await lookupHostLoopback(host);
201
- }
202
- /**
203
- * loopback 信任围栏(D31):字面量环回(绝大多数请求)**同步**判定——保持
204
- * 「请求进来即建流」的原有时序(SSE 测试与 EventSource 都依赖第一拍就写头);
205
- * 只有主机名 / /etc/hosts 别名才走异步 DNS 确认。
206
- *
207
- * **来源检查必须在解析 Host 之前**(D80):别名主机名(`127.0.0.1.nip.io`、`/etc/hosts` 里
208
- * 的别名)走的是异步分支,若在那里提前 return,`Sec-Fetch-Site` 与 Origin 两段检查会被
209
- * 整段跳过 —— 围栏等于没设,跨站页面就能写 `/config`(它不要求同源证明)。
210
- */
211
- function isLoopbackHttp(req) {
212
- if (!isLoopbackAddress(req.socket.remoteAddress))
213
- return false;
214
- const host = req.headers.host;
215
- if (typeof host !== 'string')
216
- return false;
217
- let hostUrl;
218
- try {
219
- hostUrl = new URL('http://' + host);
220
- }
221
- catch {
222
- return false;
223
- }
224
- if (req.headers['sec-fetch-site'] === 'cross-site')
225
- return false;
226
- const origin = req.headers.origin;
227
- if (origin !== undefined) {
228
- let sameOrigin = false;
229
- try {
230
- sameOrigin = new URL(origin).host === hostUrl.host;
231
- }
232
- catch {
233
- sameOrigin = false;
234
- }
235
- if (!sameOrigin)
236
- return false;
237
- }
238
- const hostname = hostUrl.hostname.toLowerCase().replace(/\.$/, '');
239
- if (hostname === 'localhost' || hostname.endsWith('.localhost') || isLoopbackAddress(hostname))
240
- return true;
241
- return hostResolvesToLoopback(hostname);
242
- }
243
- /**
244
- * 「同源证明」(D32):变更类与长流端点要求请求带 Origin(浏览器 fetch 对
245
- * cross-site 一定带)或 Sec-Fetch-Site: same-origin 之一。恶意页面可以用
246
- * `<img src="GET /images/pull/stream?...">` 触发副作用 / 拉起 docker 子进程,
247
- * 而旧 Safari / 部分 WebView 既不发 Origin 也不发 Sec-Fetch-Site——这两类端点
248
- * 对「无来源证明」的请求拒绝;只读端点维持 loopback-only 的原信任模型。
249
- *
250
- * **桌面版例外(D139)**:桌面壳把页面发往 `dsh-app://app/api/…` 的请求转给真实宿主时
251
- * **会删掉 `origin` 与 `sec-fetch-site`**(`app.asar/lib/main.js` 的 `forwardWebRequest`,
252
- * 只重写 `host` / `cookie`,其余头原样带过去),于是这四条 SSE 与八条变更路由在桌面版
253
- * **全部** 403——症状是日志 / 统计 / 活动 / 拉取四条流无限「连接中断,正在自动重连…」,
254
- * 而同一个面板的只读路由(`/containers`、`/inspect`…)照常可用(它们不要求证明)。
255
- *
256
- * 桌面壳在这条转发链上**必带宿主会话 Cookie**:`hostCookie` 由 `authenticateWebHost()`
257
- * 拿 `set-cookie` 换来,取不到时 `forwardWebRequest` 整体 503、根本走不到这里。而浏览器
258
- * 页面**伪造不了 Cookie 头**——跨站请求带不带它由 SameSite 决定,且现代浏览器一定同时带
259
- * `sec-fetch-site: cross-site`(已被上一条 loopback 围栏拒掉)。
260
- *
261
- * 所以把「两条证明都缺省」收窄成「都缺省 **且** 带宿主 Cookie」:桌面版放行、旧 Safari /
262
- * 裸 curl 仍然拒。这不是 D32 的松动——本函数从来没挡住本机进程(它们随时可以自带
263
- * `Origin: http://127.0.0.1:<port>` 过闸),防的一直是**浏览器**,而 Cookie 恰恰是浏览器
264
- * 侧最不可伪造的那一件。
265
- */
266
- function hasSameOriginProof(req) {
267
- const site = req.headers['sec-fetch-site'];
268
- if (typeof site === 'string' && site === 'same-origin')
269
- return true;
270
- const origin = req.headers.origin;
271
- if (origin !== undefined) {
272
- // Origin 出现就一律以它为准:非字符串 / 空串 / 不同源都拒,**不回落**到 Cookie——
273
- // 否则一个畸形 Origin 反倒成了绕过同源比对的入口
274
- if (typeof origin !== 'string' || origin === '')
275
- return false;
276
- const host = req.headers.host;
277
- if (typeof host !== 'string')
278
- return false;
279
- try {
280
- return new URL(origin).host === host;
281
- }
282
- catch {
283
- return false;
284
- }
285
- }
286
- const cookie = req.headers.cookie;
287
- return typeof cookie === 'string' && cookie.trim() !== '';
288
- }
289
- /**
290
- * 403 的成因摘要(D139):这次桌面版四条流全断,宿主侧**一条日志都没有**,只能靠读客户端
291
- * 源码 + 拆 `app.asar` 反推。以后同类问题第一眼就能定位:只报这三个头「有没有」,
292
- * **绝不落 Cookie 的值**(它是宿主会话凭据)。
293
- */
294
- function originProofHint(req) {
295
- const has = (name) => {
296
- const value = req.headers[name];
297
- return typeof value === 'string' && value !== '' ? '有' : '无';
298
- };
299
- return `origin=${has('origin')} sec-fetch-site=${has('sec-fetch-site')} cookie=${has('cookie')}`;
300
- }
301
151
  function writeJson(res, status, body) {
302
152
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
303
153
  res.end(JSON.stringify(body));
@@ -467,6 +317,38 @@ export function mergeTargetSecrets(prev, incoming) {
467
317
  return next;
468
318
  });
469
319
  }
320
+ /**
321
+ * 两条能力开关的宿主侧授权(见 kit 的 capability.js 与 docs/architecture.md)。
322
+ *
323
+ * 两条提权通道:**启动环境变量**(最严,见 kit 的 capability.ts)与**就地提权**(页内点开关 →
324
+ * 在宿主上落地一个随机名文件 → 免重启生效,见 kit 的 elevation.ts)。HTTP 侧不能凭空打开它们——
325
+ * 回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 `Sec-Fetch-Site: same-origin`,
326
+ * 那是请求头不是凭据),而 docker socket 等价目标主机 root。
327
+ */
328
+ const CAP_MUTATIONS = { env: 'DSH_DOCKER_ALLOW_MUTATIONS', label: '变更操作' };
329
+ const CAP_EXEC = { env: 'DSH_DOCKER_ALLOW_EXEC', label: 'exec' };
330
+ /**
331
+ * 客户端/HTTP 用的能力名 → 授权说明。**白名单**:不在表里的一律 400。
332
+ *
333
+ * 为什么要这张表:路由收到的 `capability` 是外部字符串,直接拿它当环境变量名去查授权存储,等于让
334
+ * 请求方决定「查哪个键」——今天查不出问题(存储里只有这两个键),明天就是一类越权。
335
+ */
336
+ const CAPABILITIES = new Map([
337
+ ['allowMutations', CAP_MUTATIONS],
338
+ ['allowExec', CAP_EXEC],
339
+ ]);
340
+ /** 未启用时的文案:没授权要说清「怎么授权」,授权了只是没开就说「去开开关」。 */
341
+ function mutationsOffMessage() {
342
+ return capabilityGranted(CAP_MUTATIONS)
343
+ ? '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)'
344
+ : capabilityDeniedMessage(CAP_MUTATIONS, { inPlace: true });
345
+ }
346
+ /** `exec` 那一档的同款文案。 */
347
+ function execOffMessage() {
348
+ return capabilityGranted(CAP_EXEC)
349
+ ? 'exec 未启用(插件配置 → Docker 容器面板 → 允许 exec)'
350
+ : capabilityDeniedMessage(CAP_EXEC, { inPlace: true });
351
+ }
470
352
  /** 把一份任意来源的配置归一成 LiveConfig。 */
471
353
  export function normalizeConfig(section) {
472
354
  const targets = sanitizeTargets(section.targets);
@@ -475,8 +357,14 @@ export function normalizeConfig(section) {
475
357
  enabled: section.enabled !== false,
476
358
  announceToAgent: section.announceToAgent !== false,
477
359
  dockerBin: assertBin(section.dockerBin),
478
- allowMutations: section.allowMutations === true,
479
- allowExec: section.allowExec === true,
360
+ /*
361
+ * **有效值 = 配置值 && 宿主授权**。折叠在这一处(而不是每个使用点各判一次)的理由:本包
362
+ * 有二十多处按 `live.allowMutations` 决定「工具注册 / 路由放行 / 面板渲染」,散着判必然
363
+ * 漏一处——漏掉的那处就是「没授权也能执行」。配置里的 true 不算授权:它与 HTTP 写进去的
364
+ * 值存在同一个存储里,分不出来源,只认环境变量才是能说清的规则(升级影响见 README)。
365
+ */
366
+ allowMutations: section.allowMutations === true && capabilityGranted(CAP_MUTATIONS),
367
+ allowExec: section.allowExec === true && capabilityGranted(CAP_EXEC),
480
368
  execTimeoutSec: clampInt(section.execTimeoutSec, 1, 120, 30),
481
369
  pollIntervalSec: clampInt(section.pollIntervalSec, 1, 60, 5),
482
370
  logTailDefault: clampInt(section.logTailDefault, 1, 5000, 200),
@@ -517,7 +405,11 @@ export function formatBytes(value) {
517
405
  return `${text} ${units[unit] ?? 'B'}`;
518
406
  }
519
407
  /** 从 tty 的 entry settings 读取连接簿(只读;tty 未安装时为空表)。 */
520
- function readTtyBooks(settings) {
408
+ /**
409
+ * 读 tty 连接簿 → 本包的连接规格。**导出仅供单测**(连接簿 → 规格这一跳是「一处配置、两处
410
+ * 生效」的落地处:漏带 jump / proxyCommand 就是「tty 能连、docker 连不上」那种半吊子状态)。
411
+ */
412
+ export function readTtyBooks(settings) {
521
413
  const out = new Map();
522
414
  if (settings === undefined)
523
415
  return out;
@@ -542,7 +434,7 @@ function readTtyBooks(settings) {
542
434
  const username = typeof item.username === 'string' ? item.username.trim() : '';
543
435
  if (name === '' || host === '' || username === '')
544
436
  continue;
545
- out.set(name, {
437
+ const spec = {
546
438
  host,
547
439
  port: typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22,
548
440
  username,
@@ -551,10 +443,47 @@ function readTtyBooks(settings) {
551
443
  password: typeof item.password === 'string' ? item.password : '',
552
444
  passphrase: typeof item.passphrase === 'string' ? item.passphrase : '',
553
445
  agentForward: item.agentForward === true,
554
- });
446
+ };
447
+ /*
448
+ * 跳板机跟着连接簿一起走(本包**不自建跳板机界面**:目标是「一处配置、两处生效」)。
449
+ * 漏了这一段就是「终端能连、docker 目标连不上」——本项立项时点名的半吊子状态。
450
+ */
451
+ const jump = sanitizeJumpSpec(item.jump);
452
+ if (jump !== undefined)
453
+ spec.jump = jump;
454
+ /*
455
+ * 代理命令同理跟着连接簿走(本包**不自建界面**)。闸门不在这里判:它归 tty settings 的
456
+ * `allowProxyCommand`,由 `readTtyProxyCommandAllowed()` 现读、在**每次拨号**时求值
457
+ * (见 RemoteExec 的 options.proxyCommandAllowed)——这样用户一关开关就立刻生效。
458
+ */
459
+ const proxyCommand = sanitizeProxyCommand(item.proxyCommand);
460
+ if (proxyCommand !== undefined)
461
+ spec.proxyCommand = proxyCommand;
462
+ out.set(name, spec);
555
463
  }
556
464
  return out;
557
465
  }
466
+ /**
467
+ * 读 tty settings 里的 ProxyCommand 闸门(`allowProxyCommand`,默认关)。
468
+ *
469
+ * 为什么本包要用 **tty 的**开关而不是自己再加一个:连接簿只有一处(tty),代理命令也只有
470
+ * 一处能填;两个开关会让「连接簿配了、这个面板不认」变成说不清的状态。代价是本包多依赖一个
471
+ * 只读 settings 字段——settings 句柄缺失(启动早期)时恒 false,即**关**(失败方向安全)。
472
+ */
473
+ export function readTtyProxyCommandAllowed(settings) {
474
+ if (settings === undefined)
475
+ return false;
476
+ let raw;
477
+ try {
478
+ raw = settings.get('tty');
479
+ }
480
+ catch {
481
+ return false;
482
+ }
483
+ if (typeof raw !== 'object' || raw === null)
484
+ return false;
485
+ return raw.allowProxyCommand === true;
486
+ }
558
487
  /** 从 tty 的 hostKeys 读取已钉扎指纹(作为本插件 TOFU 的种子)。 */
559
488
  function readTtyHostKeys(settings) {
560
489
  if (settings === undefined)
@@ -583,8 +512,13 @@ export function resolveTarget(target, books) {
583
512
  * 但那张卡片管的是 tty 自己的连接簿条目,改不了 docker 目标引用的名字:用户照着找
584
513
  * 只会扑空(实测:目标引用 HS-248、连接簿里只有 HS_248_ADMIN,进 tty 卡片什么也改不了)。
585
514
  * 真正要改的字段是**本卡片这条目标的「连接簿」下拉**,所以先把这里说清楚,再说备选。
515
+ *
516
+ * 末句是给「只能经跳板机 / 代理命令访问」那类情况留的出口(项目级 ROADMAP 第 2 项):
517
+ * tty 的 ~/.ssh/config 导入会**自动带上 ProxyJump**、但**永不带上 ProxyCommand**,所以
518
+ * 后者的条目要么不存在、要么存在却没配代理命令——用户看到「条目不存在」时的第一反应
519
+ * 是「我明明配过」。
586
520
  */
587
- return { error: `目标「${target.name}」引用的连接簿条目不存在:${target.book}(在本卡片这条目标的「连接簿」下拉里改选一个已有条目;或把 tty 终端面板的连接簿补一个同名条目;也可清空下拉改为手填 host/username)` };
521
+ return { error: `目标「${target.name}」引用的连接簿条目不存在:${target.book}(在本卡片这条目标的「连接簿」下拉里改选一个已有条目;或把 tty 终端面板的连接簿补一个同名条目;也可清空下拉改为手填 host/username。若该主机在 ~/.ssh/config 里只配了 ProxyCommand,导入会跳过它——那种条目要在 tty 连接簿里手动建,并打开「允许 ProxyCommand」,见项目级 ROADMAP 第 2 项)` };
588
522
  }
589
523
  return { resolved: { name: target.name, kind: 'ssh', spec } };
590
524
  }
@@ -611,8 +545,47 @@ export function resolveTarget(target, books) {
611
545
  const plugin = definePlugin({
612
546
  name: 'docker',
613
547
  apply(ctx, rawConfig) {
548
+ /*
549
+ * 授权来源必须在**第一次 capabilityGranted 之前**绑定(下面的 normalizeConfig 就会查它):
550
+ * 环境变量那一半取宿主的启动快照,就地提权那一半取带外授权存储。详见 kit 的 capability.ts /
551
+ * grant-store.ts。存储每次 apply 新建一个实例 —— 这就是「宿主重启后重新读盘」的语义。
552
+ *
553
+ * 落点全部来自 kit 的 `capabilityPaths`(`<DSH home>/dsh-kit/`):路径只在那里拼一次,
554
+ * 插件不再自己 join(kit D08:平铺在 DSH 主目录的 `capability-grants.json` / `grant-confirm/`
555
+ * 不带归属,容易与官方或别的插件撞名)。
556
+ */
557
+ const paths = capabilityPaths(dshHome());
558
+ // **共享实例**(kit D11):docker 与 tty 同装时各 new 一个会各自缓存一份文件快照,
559
+ // 后绑定的那个看不到另一个后来写进去的授权(实测:tty 授权成功后自己的快照仍是 false)
560
+ const grantStore = sharedGrantStore(paths.dir);
561
+ bindCapabilitySources(ctx, grantStore);
562
+ /*
563
+ * 启动期审计:盘上已有的带外授权是**持久**的(重启后直接生效、不再有任何一次确认),所以那次
564
+ * 「静默继承」必须在日志里留下痕迹——否则「三周前授权的能力今天一开机就开着」无可追溯(kit D09)。
565
+ * 只报本插件的两条能力;环境变量通道由启动环境本身表达,不在这里重复。
566
+ *
567
+ * 刻意放在 `enabled` 判定**之前**:授权是宿主级的、与插件这次是否启用无关;插件这次是禁用态时
568
+ * 反而更该说一句(「盘上有授权,但插件没开」与「点了没反应」是两回事)。
569
+ */
570
+ auditLoadedGrants(grantStore, [CAP_MUTATIONS.env, CAP_EXEC.env], { info: (msg) => ctx.logger.info(msg), warn: (msg) => ctx.logger.warn(msg) }, '[dsh-docker]');
614
571
  // volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
615
572
  const config = plainConfig((rawConfig ?? {}));
573
+ /*
574
+ * 配置里**写着的**能力开关值(**没和授权折叠**)。
575
+ *
576
+ * 为什么必须单独留一份:`live.allowMutations` 是「配置值 && 已授权」折叠后的**有效值**,一旦
577
+ * 用户在没授权时打开过开关,它就成了 false——此后「重算」再拿它当输入,永远回不到 true
578
+ * (授权到了、工具却不注册,就是那个半个状态)。重算必须从**配置值**出发:
579
+ * - `applySection` 把这一份塞回合并结果里(见那里的注释);
580
+ * - 快照里的 `*Configured` 字段也用它,界面靠它把开关画成「开着但未生效」。
581
+ *
582
+ * (D141/D142:折叠后的有效值当输入 = 授权到了也回不来;快照缺配置值/来源 = 界面说不出
583
+ * 「配置开着但没授权」。)
584
+ */
585
+ let configuredCapabilities = {
586
+ allowMutations: config.allowMutations === true,
587
+ allowExec: config.allowExec === true,
588
+ };
616
589
  let live = normalizeConfig(config);
617
590
  if (!live.enabled)
618
591
  return;
@@ -677,7 +650,12 @@ const plugin = definePlugin({
677
650
  persistHostKeys(next);
678
651
  },
679
652
  };
680
- const remote = new RemoteExec(logger, hostKeyStore);
653
+ /*
654
+ * ProxyCommand 闸门:**每次拨号现读** tty settings(回调只做一次对象属性读取,无 IO)。
655
+ * 不缓存成布尔值:用户随时可能关掉它,缓存意味着「关了还能用」——这一档最不能出的错。
656
+ * settingsApi 在 settings 子上下文就绪前是 undefined,那时恒 false(= 关,安全方向)。
657
+ */
658
+ const remote = new RemoteExec(logger, hostKeyStore, { proxyCommandAllowed: () => readTtyProxyCommandAllowed(settingsApi) });
681
659
  /**
682
660
  * 当前生效的 targets:**以 settings 解析值为准**。
683
661
  * 为什么不能只读内存里的 live:settings 解析是异步的(服务就绪后才读一次),存在一个
@@ -834,6 +812,29 @@ const plugin = definePlugin({
834
812
  dockerBin: live.dockerBin,
835
813
  allowMutations: live.allowMutations,
836
814
  allowExec: live.allowExec,
815
+ /*
816
+ * 配置里**写着的**值(未与授权折叠):开关的视觉状态用它,有效值用上面那两个。
817
+ * 两张都要给客户端,否则「配置开着但没授权」这个状态在界面上无法表达——那正是本仓最忌讳的
818
+ * 「配了没反应」:开关看着是开的、什么都不会发生,却没有任何东西说明原因。
819
+ */
820
+ allowMutationsConfigured: configuredCapabilities.allowMutations,
821
+ allowExecConfigured: configuredCapabilities.allowExec,
822
+ // 宿主侧授权(只读):客户端用它把开关渲染成「点不动 + 说明怎么开」
823
+ allowMutationsGranted: capabilityGranted(CAP_MUTATIONS),
824
+ allowExecGranted: capabilityGranted(CAP_EXEC),
825
+ /*
826
+ * 授权来源:'env' = 启动环境变量(界面不给「撤销」按钮,因为它只能靠改启动环境撤销)、
827
+ * 'file' = 就地确认写下的带外授权(界面可撤销)、null = 没授权。
828
+ * 客户端据此决定显示「撤销宿主授权」还是「去启动环境里去掉它」,两者完全不同。
829
+ */
830
+ allowMutationsGrantSource: capabilityGrantVia(CAP_MUTATIONS) ?? null,
831
+ allowExecGrantSource: capabilityGrantVia(CAP_EXEC) ?? null,
832
+ /*
833
+ * 授权时刻(Unix 秒;只有 file 通道有值,环境变量通道与未授权都是 null)。授权是**持久**
834
+ * 的:重启后它直接生效、不再确认,所以界面至少要能说出「它是什么时候来的」(D09)。
835
+ */
836
+ allowMutationsGrantedAt: capabilityGrantAt(CAP_MUTATIONS) ?? null,
837
+ allowExecGrantedAt: capabilityGrantAt(CAP_EXEC) ?? null,
837
838
  execTimeoutSec: live.execTimeoutSec,
838
839
  pollIntervalSec: live.pollIntervalSec,
839
840
  logTailDefault: live.logTailDefault,
@@ -1055,8 +1056,14 @@ const plugin = definePlugin({
1055
1056
  };
1056
1057
  const applySection = (section, options) => {
1057
1058
  const before = live;
1059
+ // 能力开关的**配置值**单独记(D141):section 里没提到它就保持原样(`{}` = 只重算、不改配置)
1060
+ if (section.allowMutations !== undefined)
1061
+ configuredCapabilities.allowMutations = section.allowMutations === true;
1062
+ if (section.allowExec !== undefined)
1063
+ configuredCapabilities.allowExec = section.allowExec === true;
1058
1064
  // hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
1059
- const merged = { ...live, ...section };
1065
+ // 能力两项用**配置值**覆盖折叠后的 live:否则重算的输入就是折叠后的 false(见 configuredCapabilities)
1066
+ const merged = { ...live, ...section, ...configuredCapabilities };
1060
1067
  if (section.hostKeys === undefined)
1061
1068
  merged.hostKeys = live.hostKeys;
1062
1069
  live = normalizeConfig(merged);
@@ -1087,6 +1094,29 @@ const plugin = definePlugin({
1087
1094
  }
1088
1095
  console.log(`[dsh-docker] config applied (enabled=${String(live.enabled)}, bin=${live.dockerBin}, targets=${String(live.targets.length)}, allowMutations=${String(live.allowMutations)}, allowExec=${String(live.allowExec)})`);
1089
1096
  };
1097
+ /*
1098
+ * 就地提权(见 kit 的 elevation.ts):页内点开关 → 在宿主上落地一个随机名确认文件 → 授权。
1099
+ *
1100
+ * `onGrantChange` 是这一整段里**最容易漏、也最要紧**的一下:授权到达与撤销都必须重算 live。
1101
+ * - 授权侧:配置开关可能**早就**是 true(用户先打开过、当时没授权,于是 live.allowMutations
1102
+ * 被折叠成 false),授权到了却不重算,症状就是「开关亮着、工具却没注册」;
1103
+ * - 撤销侧:不重算会留下「记录没了、工具还开着」,还要收掉在途的写流(D84)。
1104
+ * `applySection({})` 用当前 live 兜底再归一化一次即可(normalizeConfig 会重新问 capabilityGranted)。
1105
+ */
1106
+ const elevation = createElevationManager({
1107
+ confirmDir: paths.confirmDir,
1108
+ store: grantStore,
1109
+ logger,
1110
+ logPrefix: '[dsh-docker]',
1111
+ onGrantChange: () => {
1112
+ applySection({}, { forceRefreshTools: true });
1113
+ },
1114
+ });
1115
+ ctx.effect(() => {
1116
+ return () => {
1117
+ elevation.dispose();
1118
+ };
1119
+ }, 'dsh-docker: elevation cleanup');
1090
1120
  /* ---------- agent 工具 ---------- */
1091
1121
  let toolsApi;
1092
1122
  let toolDisposers = [];
@@ -1299,6 +1329,8 @@ const plugin = definePlugin({
1299
1329
  };
1300
1330
  add('docker_targets', defineTool({
1301
1331
  name: 'docker_targets',
1332
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1333
+ isConcurrencySafe: () => true,
1302
1334
  description: '列出已配置的 Docker 目标(本机 / SSH 主机),可选探测每个目标的 docker daemon 是否可达。其他 docker_* 工具的 target 参数取自这里。',
1303
1335
  parameters: { probe: { type: 'boolean', description: 'true 时逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)' } },
1304
1336
  output: {
@@ -1365,6 +1397,8 @@ const plugin = definePlugin({
1365
1397
  }));
1366
1398
  add('docker_ps', defineTool({
1367
1399
  name: 'docker_ps',
1400
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1401
+ isConcurrencySafe: () => true,
1368
1402
  description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` = 一次列出所有目标**(跨主机,按目标分组返回,单个目标不可达不影响其他目标)。排障第一步。注意:`ports` 为空**不等于**「没暴露端口」——host 网络容器的端口就是宿主机端口、ps 里没有映射,这种情况会给 `net` 字段(如 `net:"host"`),别为此再逐个 docker_inspect。',
1369
1403
  parameters: {
1370
1404
  target: { type: 'string', description: '目标名;传 `*` 或省略(仅一个目标时)表示当前目标/全部目标(docker_targets 列出)' },
@@ -1480,6 +1514,8 @@ const plugin = definePlugin({
1480
1514
  }));
1481
1515
  add('docker_attention', defineTool({
1482
1516
  name: 'docker_attention',
1517
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1518
+ isConcurrencySafe: () => true,
1483
1519
  description: '列出「需要关注」的容器:不健康(unhealthy)/ 反复重启 / 被 OOM 杀 / 非零退出 / 僵死。target 传 `*` 时**跨所有目标聚合**(单目标不可达不影响其他目标)。排障入口:不确定从哪台机器看起时先调它。',
1484
1520
  parameters: {
1485
1521
  target: { type: 'string', description: '目标名;传 `*` 表示全部目标(docker_targets 列出)' },
@@ -1622,6 +1658,8 @@ const plugin = definePlugin({
1622
1658
  }));
1623
1659
  add('docker_inspect', defineTool({
1624
1660
  name: 'docker_inspect',
1661
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1662
+ isConcurrencySafe: () => true,
1625
1663
  description: '读取某个容器的权威详情(docker inspect):状态/健康检查/退出码/重启次数/端口映射/挂载/网络/启动命令。',
1626
1664
  parameters: { target: targetParam, id: { type: 'string', required: true, description: '容器名或 ID(来自 docker_ps)' } },
1627
1665
  output: {
@@ -1676,6 +1714,8 @@ const plugin = definePlugin({
1676
1714
  }));
1677
1715
  add('docker_logs', defineTool({
1678
1716
  name: 'docker_logs',
1717
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1718
+ isConcurrencySafe: () => true,
1679
1719
  description: '读取某个容器的日志尾部(docker logs --tail)。默认行数取插件配置 logTailDefault(出厂 200)、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
1680
1720
  parameters: {
1681
1721
  target: targetParam,
@@ -1724,6 +1764,8 @@ const plugin = definePlugin({
1724
1764
  }));
1725
1765
  add('docker_stats', defineTool({
1726
1766
  name: 'docker_stats',
1767
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1768
+ isConcurrencySafe: () => true,
1727
1769
  description: '读取容器实时资源占用(docker stats --no-stream):CPU%、内存用量/上限、网络与磁盘 IO、PIDs。不传 ids 时返回该目标上全部运行中容器。',
1728
1770
  parameters: {
1729
1771
  target: targetParam,
@@ -1794,6 +1836,8 @@ const plugin = definePlugin({
1794
1836
  }));
1795
1837
  add('docker_events', defineTool({
1796
1838
  name: 'docker_events',
1839
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1840
+ isConcurrencySafe: () => true,
1797
1841
  description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
1798
1842
  parameters: {
1799
1843
  target: targetParam,
@@ -1857,6 +1901,8 @@ const plugin = definePlugin({
1857
1901
  }));
1858
1902
  add('docker_images', defineTool({
1859
1903
  name: 'docker_images',
1904
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1905
+ isConcurrencySafe: () => true,
1860
1906
  description: '列出某个目标上的镜像(仓库:标签、大小、创建时间、短 ID)。',
1861
1907
  parameters: { target: targetParam },
1862
1908
  output: {
@@ -1908,6 +1954,8 @@ const plugin = definePlugin({
1908
1954
  }));
1909
1955
  add('docker_image_inspect', defineTool({
1910
1956
  name: 'docker_image_inspect',
1957
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1958
+ isConcurrencySafe: () => true,
1911
1959
  description: '读取某个镜像的详情(docker image inspect)与构建历史(docker history):大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 每步构建命令与大小。',
1912
1960
  parameters: {
1913
1961
  target: targetParam,
@@ -1944,6 +1992,8 @@ const plugin = definePlugin({
1944
1992
  }));
1945
1993
  add('docker_networks', defineTool({
1946
1994
  name: 'docker_networks',
1995
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1996
+ isConcurrencySafe: () => true,
1947
1997
  description: '列出某个目标上的 docker 网络(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表要进详情页看,不在列表里逐条 inspect。',
1948
1998
  parameters: { target: targetParam },
1949
1999
  output: {
@@ -1997,6 +2047,8 @@ const plugin = definePlugin({
1997
2047
  }));
1998
2048
  add('docker_volumes', defineTool({
1999
2049
  name: 'docker_volumes',
2050
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
2051
+ isConcurrencySafe: () => true,
2000
2052
  description: '列出某个目标上的 docker 卷(名称 / 驱动 / 范围 / 挂载点)。',
2001
2053
  parameters: { target: targetParam },
2002
2054
  output: {
@@ -2075,7 +2127,7 @@ const plugin = definePlugin({
2075
2127
  },
2076
2128
  async execute(args) {
2077
2129
  if (!live.allowMutations)
2078
- throw new Error('变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)');
2130
+ throw new Error(mutationsOffMessage());
2079
2131
  const input = (args ?? {});
2080
2132
  const picked = pickTarget(input.target);
2081
2133
  if (picked.name === undefined)
@@ -2117,7 +2169,7 @@ const plugin = definePlugin({
2117
2169
  },
2118
2170
  async execute(args) {
2119
2171
  if (!live.allowMutations)
2120
- throw new Error('变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)');
2172
+ throw new Error(mutationsOffMessage());
2121
2173
  const input = (args ?? {});
2122
2174
  const picked = pickTarget(input.target);
2123
2175
  if (picked.name === undefined)
@@ -2151,7 +2203,7 @@ const plugin = definePlugin({
2151
2203
  },
2152
2204
  async execute(args) {
2153
2205
  if (!live.allowMutations)
2154
- throw new Error('变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)');
2206
+ throw new Error(mutationsOffMessage());
2155
2207
  const input = (args ?? {});
2156
2208
  const picked = pickTarget(input.target);
2157
2209
  if (picked.name === undefined)
@@ -2191,7 +2243,7 @@ const plugin = definePlugin({
2191
2243
  },
2192
2244
  async execute(args) {
2193
2245
  if (!live.allowMutations)
2194
- throw new Error('变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)');
2246
+ throw new Error(mutationsOffMessage());
2195
2247
  const input = (args ?? {});
2196
2248
  const picked = pickTarget(input.target);
2197
2249
  if (picked.name === undefined)
@@ -2250,7 +2302,7 @@ const plugin = definePlugin({
2250
2302
  },
2251
2303
  async execute(args) {
2252
2304
  if (!live.allowExec)
2253
- throw new Error('exec 未启用(插件配置 → Docker 容器面板 → 允许 exec)');
2305
+ throw new Error(execOffMessage());
2254
2306
  const input = (args ?? {});
2255
2307
  const picked = pickTarget(input.target);
2256
2308
  if (picked.name === undefined)
@@ -2590,7 +2642,7 @@ const plugin = definePlugin({
2590
2642
  */
2591
2643
  const servePullStream = async (req, res, params) => {
2592
2644
  if (!live.allowMutations) {
2593
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
2645
+ writeJson(res, 403, { error: mutationsOffMessage() });
2594
2646
  return;
2595
2647
  }
2596
2648
  const picked = pickTarget(params.get('target'));
@@ -2638,7 +2690,7 @@ const plugin = definePlugin({
2638
2690
  kind: 'prefix',
2639
2691
  path: ROUTE_PREFIX,
2640
2692
  handler: async (req, res) => {
2641
- const loopback = isLoopbackHttp(req);
2693
+ const loopback = isLoopbackRequestStrict(req);
2642
2694
  // 字面量环回同步判定(保持「第一拍就建流」的时序);仅别名主机名才等 DNS
2643
2695
  if (loopback instanceof Promise) {
2644
2696
  if (!(await loopback)) {
@@ -2651,10 +2703,11 @@ const plugin = definePlugin({
2651
2703
  return;
2652
2704
  }
2653
2705
  const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice(ROUTE_PREFIX.length);
2654
- // 插件禁用时只保留 /config 读写:设置卡片靠它渲染,也是重新启用插件的唯一
2655
- // UI 入口(不能一并关掉,否则卡片消失就没有恢复路径了);其余数据路由一律
2656
- // 403——agent 工具已由 refreshTools 同步清空,这里只管 HTTP 半体。
2657
- if (!live.enabled && sub !== '/config') {
2706
+ // 插件禁用时只保留 /config 读写与 /elevate 族:设置卡片靠前者渲染,也是重新启用插件的
2707
+ // 唯一 UI 入口(不能一并关掉,否则卡片消失就没有恢复路径了);授权的宿主级操作也不该
2708
+ // 因为插件被停用而失效(用户先授权再启用插件同样合理)。其余数据路由一律 403——
2709
+ // agent 工具已由 refreshTools 同步清空,这里只管 HTTP 半体。
2710
+ if (!live.enabled && sub !== '/config' && !sub.startsWith('/elevate')) {
2658
2711
  writeJson(res, 403, { error: '插件已禁用(插件配置 → Docker 容器面板 → 启用插件)' });
2659
2712
  return;
2660
2713
  }
@@ -2683,6 +2736,18 @@ const plugin = definePlugin({
2683
2736
  continue;
2684
2737
  patch[key] = body[key];
2685
2738
  }
2739
+ /*
2740
+ * **能力开关不能凭空升**:提权只认宿主侧来源(启动环境变量,或带外确认写下的授权)。
2741
+ * HTTP 侧给 true 而没有授权一律驳回并说清两条路。这不是「输入不合法」而是「没获授权」——
2742
+ * 文案必须给出**怎么授权**,否则用户会对着一个点不动的开关反复点。
2743
+ * 校验放在**落盘之前**(与下面那条 normalizeConfig 干跑同一口径):错误路径不留脏配置。
2744
+ */
2745
+ for (const [key, spec] of [['allowMutations', CAP_MUTATIONS], ['allowExec', CAP_EXEC]]) {
2746
+ if (patch[key] === true && !capabilityGranted(spec)) {
2747
+ writeJson(res, 400, { error: capabilityDeniedMessage(spec, { inPlace: true }) });
2748
+ return;
2749
+ }
2750
+ }
2686
2751
  // 空 targets 只在显式 clearTargets 时才允许清空:卡片若因启动竞态拿到
2687
2752
  // 空列表,保存不会再把已配置的目标抹掉(防数据丢失)
2688
2753
  if (Array.isArray(patch.targets) && patch.targets.length === 0 && body.clearTargets !== true) {
@@ -2855,6 +2920,42 @@ const plugin = definePlugin({
2855
2920
  writeJson(res, 400, { error: 'invalid JSON body' });
2856
2921
  return;
2857
2922
  }
2923
+ /*
2924
+ * 就地提权(三条子路由)。放在这里而不是 `/config` 旁边是有原因的:**同源证明由上面那段
2925
+ * MUTATION_SUBROUTES 判据提供**,而它只覆盖它后面这段分发——写在它前面就是裸的了
2926
+ * (跨站页面能撤销授权、能反复试确认)。三条都做成 POST 也是为了复用那一条判据
2927
+ * (它只认 POST;`status` 做成 GET 会绕过)。
2928
+ */
2929
+ if (sub === '/elevate' || sub === '/elevate/status' || sub === '/elevate/revoke') {
2930
+ const capability = typeof body.capability === 'string' ? body.capability : '';
2931
+ const spec = CAPABILITIES.get(capability);
2932
+ if (spec === undefined) {
2933
+ writeJson(res, 400, { error: `未知能力开关: ${capability === '' ? '(空)' : capability}(可用:${[...CAPABILITIES.keys()].join(' / ')})` });
2934
+ return;
2935
+ }
2936
+ if (sub === '/elevate/status') {
2937
+ writeJson(res, 200, elevation.status(spec.env));
2938
+ return;
2939
+ }
2940
+ if (sub === '/elevate/revoke') {
2941
+ // 降权零门槛:不要求任何确认(紧急刹车不等重启)。撤销带来的重算由 elevation 的
2942
+ // onGrantChange 回调负责(不变量 6),这里不再补一刀。
2943
+ const revoked = elevation.revoke(spec.env);
2944
+ writeJson(res, 200, { ok: true, revoked, config: snapshot() });
2945
+ return;
2946
+ }
2947
+ const result = elevation.begin(spec.env);
2948
+ if (result.status === 'rate-limited') {
2949
+ writeJson(res, 429, { ...result, error: '短时间内发起太多次,请稍后再试' });
2950
+ return;
2951
+ }
2952
+ if (result.status === 'error') {
2953
+ writeJson(res, 500, result);
2954
+ return;
2955
+ }
2956
+ writeJson(res, 200, result);
2957
+ return;
2958
+ }
2858
2959
  // 跨目标聚合(0.15.0):target='*' 不是目标名,必须在 pickTarget 之前分流,
2859
2960
  // 否则会被当成「未知目标」直接 400
2860
2961
  const wantsAllTargets = typeof body.target === 'string' && body.target.trim() === '*';
@@ -2978,7 +3079,7 @@ const plugin = definePlugin({
2978
3079
  }
2979
3080
  case '/images/remove': {
2980
3081
  if (!live.allowMutations) {
2981
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3082
+ writeJson(res, 403, { error: mutationsOffMessage() });
2982
3083
  return;
2983
3084
  }
2984
3085
  if (typeof body.ref !== 'string' || body.ref.trim() === '') {
@@ -2990,7 +3091,7 @@ const plugin = definePlugin({
2990
3091
  }
2991
3092
  case '/images/prune': {
2992
3093
  if (!live.allowMutations) {
2993
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3094
+ writeJson(res, 403, { error: mutationsOffMessage() });
2994
3095
  return;
2995
3096
  }
2996
3097
  writeJson(res, 200, { ok: true, result: await api.imagePrune() });
@@ -3010,7 +3111,7 @@ const plugin = definePlugin({
3010
3111
  }
3011
3112
  case '/networks/remove': {
3012
3113
  if (!live.allowMutations) {
3013
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3114
+ writeJson(res, 403, { error: mutationsOffMessage() });
3014
3115
  return;
3015
3116
  }
3016
3117
  if (typeof body.name !== 'string' || body.name.trim() === '') {
@@ -3022,7 +3123,7 @@ const plugin = definePlugin({
3022
3123
  }
3023
3124
  case '/networks/prune': {
3024
3125
  if (!live.allowMutations) {
3025
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3126
+ writeJson(res, 403, { error: mutationsOffMessage() });
3026
3127
  return;
3027
3128
  }
3028
3129
  writeJson(res, 200, { ok: true, result: await api.networkPrune() });
@@ -3042,7 +3143,7 @@ const plugin = definePlugin({
3042
3143
  }
3043
3144
  case '/volumes/remove': {
3044
3145
  if (!live.allowMutations) {
3045
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3146
+ writeJson(res, 403, { error: mutationsOffMessage() });
3046
3147
  return;
3047
3148
  }
3048
3149
  if (typeof body.name !== 'string' || body.name.trim() === '') {
@@ -3054,7 +3155,7 @@ const plugin = definePlugin({
3054
3155
  }
3055
3156
  case '/volumes/prune': {
3056
3157
  if (!live.allowMutations) {
3057
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3158
+ writeJson(res, 403, { error: mutationsOffMessage() });
3058
3159
  return;
3059
3160
  }
3060
3161
  writeJson(res, 200, { ok: true, result: await api.volumePrune() });
@@ -3062,7 +3163,7 @@ const plugin = definePlugin({
3062
3163
  }
3063
3164
  case '/action': {
3064
3165
  if (!live.allowMutations) {
3065
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3166
+ writeJson(res, 403, { error: mutationsOffMessage() });
3066
3167
  return;
3067
3168
  }
3068
3169
  if (typeof body.id !== 'string') {
@@ -3079,7 +3180,7 @@ const plugin = definePlugin({
3079
3180
  }
3080
3181
  case '/exec': {
3081
3182
  if (!live.allowExec) {
3082
- writeJson(res, 403, { error: 'exec 未启用(插件配置 → Docker 容器面板 → 允许 exec)' });
3183
+ writeJson(res, 403, { error: execOffMessage() });
3083
3184
  return;
3084
3185
  }
3085
3186
  if (typeof body.id !== 'string' || typeof body.command !== 'string') {
@@ -3124,10 +3225,13 @@ const plugin = definePlugin({
3124
3225
  const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
3125
3226
  settingsScope = scope;
3126
3227
  settingsApi = { get: (ns) => readSettingsEntry(settingsCtx, ns) };
3127
- // 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)
3228
+ // 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)。
3229
+ // 走 applySection 而不是直接 normalizeConfig:能力开关的**配置值**要一起记下来(见
3230
+ // configuredCapabilities),否则此后的就地提权重算会拿折叠后的 false 当输入。
3128
3231
  const resolved = scope.get();
3129
- if (typeof resolved === 'object' && resolved !== null)
3130
- live = normalizeConfig(resolved);
3232
+ if (typeof resolved === 'object' && resolved !== null) {
3233
+ applySection(resolved, { forceRefreshTools: true });
3234
+ }
3131
3235
  const diag = (resolved ?? {});
3132
3236
  console.log(`[dsh-docker] settings resolved (keys=${Object.keys(diag).join('|')}, targets=${Array.isArray(diag.targets) ? String(diag.targets.length) : 'not-array'}, hostKeys=${Array.isArray(diag.hostKeys) ? String(diag.hostKeys.length) : 'not-array'})`);
3133
3237
  refreshTools();