@hyzyn/dsh-docker 0.8.0 → 0.9.0-rc.1

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,136 +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
- function hasSameOriginProof(req) {
251
- const site = req.headers['sec-fetch-site'];
252
- if (typeof site === 'string' && site === 'same-origin')
253
- return true;
254
- const origin = req.headers.origin;
255
- if (typeof origin !== 'string' || origin === '')
256
- return false;
257
- const host = req.headers.host;
258
- if (typeof host !== 'string')
259
- return false;
260
- try {
261
- return new URL(origin).host === host;
262
- }
263
- catch {
264
- return false;
265
- }
266
- }
267
151
  function writeJson(res, status, body) {
268
152
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
269
153
  res.end(JSON.stringify(body));
@@ -433,6 +317,38 @@ export function mergeTargetSecrets(prev, incoming) {
433
317
  return next;
434
318
  });
435
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
+ }
436
352
  /** 把一份任意来源的配置归一成 LiveConfig。 */
437
353
  export function normalizeConfig(section) {
438
354
  const targets = sanitizeTargets(section.targets);
@@ -441,8 +357,14 @@ export function normalizeConfig(section) {
441
357
  enabled: section.enabled !== false,
442
358
  announceToAgent: section.announceToAgent !== false,
443
359
  dockerBin: assertBin(section.dockerBin),
444
- allowMutations: section.allowMutations === true,
445
- 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),
446
368
  execTimeoutSec: clampInt(section.execTimeoutSec, 1, 120, 30),
447
369
  pollIntervalSec: clampInt(section.pollIntervalSec, 1, 60, 5),
448
370
  logTailDefault: clampInt(section.logTailDefault, 1, 5000, 200),
@@ -483,7 +405,11 @@ export function formatBytes(value) {
483
405
  return `${text} ${units[unit] ?? 'B'}`;
484
406
  }
485
407
  /** 从 tty 的 entry settings 读取连接簿(只读;tty 未安装时为空表)。 */
486
- function readTtyBooks(settings) {
408
+ /**
409
+ * 读 tty 连接簿 → 本包的连接规格。**导出仅供单测**(连接簿 → 规格这一跳是「一处配置、两处
410
+ * 生效」的落地处:漏带 jump / proxyCommand 就是「tty 能连、docker 连不上」那种半吊子状态)。
411
+ */
412
+ export function readTtyBooks(settings) {
487
413
  const out = new Map();
488
414
  if (settings === undefined)
489
415
  return out;
@@ -508,7 +434,7 @@ function readTtyBooks(settings) {
508
434
  const username = typeof item.username === 'string' ? item.username.trim() : '';
509
435
  if (name === '' || host === '' || username === '')
510
436
  continue;
511
- out.set(name, {
437
+ const spec = {
512
438
  host,
513
439
  port: typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22,
514
440
  username,
@@ -517,10 +443,47 @@ function readTtyBooks(settings) {
517
443
  password: typeof item.password === 'string' ? item.password : '',
518
444
  passphrase: typeof item.passphrase === 'string' ? item.passphrase : '',
519
445
  agentForward: item.agentForward === true,
520
- });
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);
521
463
  }
522
464
  return out;
523
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
+ }
524
487
  /** 从 tty 的 hostKeys 读取已钉扎指纹(作为本插件 TOFU 的种子)。 */
525
488
  function readTtyHostKeys(settings) {
526
489
  if (settings === undefined)
@@ -549,8 +512,13 @@ export function resolveTarget(target, books) {
549
512
  * 但那张卡片管的是 tty 自己的连接簿条目,改不了 docker 目标引用的名字:用户照着找
550
513
  * 只会扑空(实测:目标引用 HS-248、连接簿里只有 HS_248_ADMIN,进 tty 卡片什么也改不了)。
551
514
  * 真正要改的字段是**本卡片这条目标的「连接簿」下拉**,所以先把这里说清楚,再说备选。
515
+ *
516
+ * 末句是给「只能经跳板机 / 代理命令访问」那类情况留的出口(项目级 ROADMAP 第 2 项):
517
+ * tty 的 ~/.ssh/config 导入会**自动带上 ProxyJump**、但**永不带上 ProxyCommand**,所以
518
+ * 后者的条目要么不存在、要么存在却没配代理命令——用户看到「条目不存在」时的第一反应
519
+ * 是「我明明配过」。
552
520
  */
553
- 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 项)` };
554
522
  }
555
523
  return { resolved: { name: target.name, kind: 'ssh', spec } };
556
524
  }
@@ -577,8 +545,47 @@ export function resolveTarget(target, books) {
577
545
  const plugin = definePlugin({
578
546
  name: 'docker',
579
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]');
580
571
  // volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
581
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
+ };
582
589
  let live = normalizeConfig(config);
583
590
  if (!live.enabled)
584
591
  return;
@@ -643,7 +650,12 @@ const plugin = definePlugin({
643
650
  persistHostKeys(next);
644
651
  },
645
652
  };
646
- 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) });
647
659
  /**
648
660
  * 当前生效的 targets:**以 settings 解析值为准**。
649
661
  * 为什么不能只读内存里的 live:settings 解析是异步的(服务就绪后才读一次),存在一个
@@ -800,6 +812,29 @@ const plugin = definePlugin({
800
812
  dockerBin: live.dockerBin,
801
813
  allowMutations: live.allowMutations,
802
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,
803
838
  execTimeoutSec: live.execTimeoutSec,
804
839
  pollIntervalSec: live.pollIntervalSec,
805
840
  logTailDefault: live.logTailDefault,
@@ -1021,8 +1056,14 @@ const plugin = definePlugin({
1021
1056
  };
1022
1057
  const applySection = (section, options) => {
1023
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;
1024
1064
  // hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
1025
- const merged = { ...live, ...section };
1065
+ // 能力两项用**配置值**覆盖折叠后的 live:否则重算的输入就是折叠后的 false(见 configuredCapabilities)
1066
+ const merged = { ...live, ...section, ...configuredCapabilities };
1026
1067
  if (section.hostKeys === undefined)
1027
1068
  merged.hostKeys = live.hostKeys;
1028
1069
  live = normalizeConfig(merged);
@@ -1053,6 +1094,29 @@ const plugin = definePlugin({
1053
1094
  }
1054
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)})`);
1055
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');
1056
1120
  /* ---------- agent 工具 ---------- */
1057
1121
  let toolsApi;
1058
1122
  let toolDisposers = [];
@@ -1265,6 +1329,8 @@ const plugin = definePlugin({
1265
1329
  };
1266
1330
  add('docker_targets', defineTool({
1267
1331
  name: 'docker_targets',
1332
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1333
+ isConcurrencySafe: () => true,
1268
1334
  description: '列出已配置的 Docker 目标(本机 / SSH 主机),可选探测每个目标的 docker daemon 是否可达。其他 docker_* 工具的 target 参数取自这里。',
1269
1335
  parameters: { probe: { type: 'boolean', description: 'true 时逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)' } },
1270
1336
  output: {
@@ -1331,6 +1397,8 @@ const plugin = definePlugin({
1331
1397
  }));
1332
1398
  add('docker_ps', defineTool({
1333
1399
  name: 'docker_ps',
1400
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1401
+ isConcurrencySafe: () => true,
1334
1402
  description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` = 一次列出所有目标**(跨主机,按目标分组返回,单个目标不可达不影响其他目标)。排障第一步。注意:`ports` 为空**不等于**「没暴露端口」——host 网络容器的端口就是宿主机端口、ps 里没有映射,这种情况会给 `net` 字段(如 `net:"host"`),别为此再逐个 docker_inspect。',
1335
1403
  parameters: {
1336
1404
  target: { type: 'string', description: '目标名;传 `*` 或省略(仅一个目标时)表示当前目标/全部目标(docker_targets 列出)' },
@@ -1446,6 +1514,8 @@ const plugin = definePlugin({
1446
1514
  }));
1447
1515
  add('docker_attention', defineTool({
1448
1516
  name: 'docker_attention',
1517
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1518
+ isConcurrencySafe: () => true,
1449
1519
  description: '列出「需要关注」的容器:不健康(unhealthy)/ 反复重启 / 被 OOM 杀 / 非零退出 / 僵死。target 传 `*` 时**跨所有目标聚合**(单目标不可达不影响其他目标)。排障入口:不确定从哪台机器看起时先调它。',
1450
1520
  parameters: {
1451
1521
  target: { type: 'string', description: '目标名;传 `*` 表示全部目标(docker_targets 列出)' },
@@ -1588,6 +1658,8 @@ const plugin = definePlugin({
1588
1658
  }));
1589
1659
  add('docker_inspect', defineTool({
1590
1660
  name: 'docker_inspect',
1661
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1662
+ isConcurrencySafe: () => true,
1591
1663
  description: '读取某个容器的权威详情(docker inspect):状态/健康检查/退出码/重启次数/端口映射/挂载/网络/启动命令。',
1592
1664
  parameters: { target: targetParam, id: { type: 'string', required: true, description: '容器名或 ID(来自 docker_ps)' } },
1593
1665
  output: {
@@ -1642,6 +1714,8 @@ const plugin = definePlugin({
1642
1714
  }));
1643
1715
  add('docker_logs', defineTool({
1644
1716
  name: 'docker_logs',
1717
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1718
+ isConcurrencySafe: () => true,
1645
1719
  description: '读取某个容器的日志尾部(docker logs --tail)。默认行数取插件配置 logTailDefault(出厂 200)、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
1646
1720
  parameters: {
1647
1721
  target: targetParam,
@@ -1690,6 +1764,8 @@ const plugin = definePlugin({
1690
1764
  }));
1691
1765
  add('docker_stats', defineTool({
1692
1766
  name: 'docker_stats',
1767
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1768
+ isConcurrencySafe: () => true,
1693
1769
  description: '读取容器实时资源占用(docker stats --no-stream):CPU%、内存用量/上限、网络与磁盘 IO、PIDs。不传 ids 时返回该目标上全部运行中容器。',
1694
1770
  parameters: {
1695
1771
  target: targetParam,
@@ -1760,6 +1836,8 @@ const plugin = definePlugin({
1760
1836
  }));
1761
1837
  add('docker_events', defineTool({
1762
1838
  name: 'docker_events',
1839
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1840
+ isConcurrencySafe: () => true,
1763
1841
  description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
1764
1842
  parameters: {
1765
1843
  target: targetParam,
@@ -1823,6 +1901,8 @@ const plugin = definePlugin({
1823
1901
  }));
1824
1902
  add('docker_images', defineTool({
1825
1903
  name: 'docker_images',
1904
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1905
+ isConcurrencySafe: () => true,
1826
1906
  description: '列出某个目标上的镜像(仓库:标签、大小、创建时间、短 ID)。',
1827
1907
  parameters: { target: targetParam },
1828
1908
  output: {
@@ -1874,6 +1954,8 @@ const plugin = definePlugin({
1874
1954
  }));
1875
1955
  add('docker_image_inspect', defineTool({
1876
1956
  name: 'docker_image_inspect',
1957
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1958
+ isConcurrencySafe: () => true,
1877
1959
  description: '读取某个镜像的详情(docker image inspect)与构建历史(docker history):大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 每步构建命令与大小。',
1878
1960
  parameters: {
1879
1961
  target: targetParam,
@@ -1910,6 +1992,8 @@ const plugin = definePlugin({
1910
1992
  }));
1911
1993
  add('docker_networks', defineTool({
1912
1994
  name: 'docker_networks',
1995
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
1996
+ isConcurrencySafe: () => true,
1913
1997
  description: '列出某个目标上的 docker 网络(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表要进详情页看,不在列表里逐条 inspect。',
1914
1998
  parameters: { target: targetParam },
1915
1999
  output: {
@@ -1963,6 +2047,8 @@ const plugin = definePlugin({
1963
2047
  }));
1964
2048
  add('docker_volumes', defineTool({
1965
2049
  name: 'docker_volumes',
2050
+ // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
2051
+ isConcurrencySafe: () => true,
1966
2052
  description: '列出某个目标上的 docker 卷(名称 / 驱动 / 范围 / 挂载点)。',
1967
2053
  parameters: { target: targetParam },
1968
2054
  output: {
@@ -2041,7 +2127,7 @@ const plugin = definePlugin({
2041
2127
  },
2042
2128
  async execute(args) {
2043
2129
  if (!live.allowMutations)
2044
- throw new Error('变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)');
2130
+ throw new Error(mutationsOffMessage());
2045
2131
  const input = (args ?? {});
2046
2132
  const picked = pickTarget(input.target);
2047
2133
  if (picked.name === undefined)
@@ -2083,7 +2169,7 @@ const plugin = definePlugin({
2083
2169
  },
2084
2170
  async execute(args) {
2085
2171
  if (!live.allowMutations)
2086
- throw new Error('变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)');
2172
+ throw new Error(mutationsOffMessage());
2087
2173
  const input = (args ?? {});
2088
2174
  const picked = pickTarget(input.target);
2089
2175
  if (picked.name === undefined)
@@ -2117,7 +2203,7 @@ const plugin = definePlugin({
2117
2203
  },
2118
2204
  async execute(args) {
2119
2205
  if (!live.allowMutations)
2120
- throw new Error('变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)');
2206
+ throw new Error(mutationsOffMessage());
2121
2207
  const input = (args ?? {});
2122
2208
  const picked = pickTarget(input.target);
2123
2209
  if (picked.name === undefined)
@@ -2157,7 +2243,7 @@ const plugin = definePlugin({
2157
2243
  },
2158
2244
  async execute(args) {
2159
2245
  if (!live.allowMutations)
2160
- throw new Error('变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)');
2246
+ throw new Error(mutationsOffMessage());
2161
2247
  const input = (args ?? {});
2162
2248
  const picked = pickTarget(input.target);
2163
2249
  if (picked.name === undefined)
@@ -2216,7 +2302,7 @@ const plugin = definePlugin({
2216
2302
  },
2217
2303
  async execute(args) {
2218
2304
  if (!live.allowExec)
2219
- throw new Error('exec 未启用(插件配置 → Docker 容器面板 → 允许 exec)');
2305
+ throw new Error(execOffMessage());
2220
2306
  const input = (args ?? {});
2221
2307
  const picked = pickTarget(input.target);
2222
2308
  if (picked.name === undefined)
@@ -2556,7 +2642,7 @@ const plugin = definePlugin({
2556
2642
  */
2557
2643
  const servePullStream = async (req, res, params) => {
2558
2644
  if (!live.allowMutations) {
2559
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
2645
+ writeJson(res, 403, { error: mutationsOffMessage() });
2560
2646
  return;
2561
2647
  }
2562
2648
  const picked = pickTarget(params.get('target'));
@@ -2604,7 +2690,7 @@ const plugin = definePlugin({
2604
2690
  kind: 'prefix',
2605
2691
  path: ROUTE_PREFIX,
2606
2692
  handler: async (req, res) => {
2607
- const loopback = isLoopbackHttp(req);
2693
+ const loopback = isLoopbackRequestStrict(req);
2608
2694
  // 字面量环回同步判定(保持「第一拍就建流」的时序);仅别名主机名才等 DNS
2609
2695
  if (loopback instanceof Promise) {
2610
2696
  if (!(await loopback)) {
@@ -2617,10 +2703,11 @@ const plugin = definePlugin({
2617
2703
  return;
2618
2704
  }
2619
2705
  const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice(ROUTE_PREFIX.length);
2620
- // 插件禁用时只保留 /config 读写:设置卡片靠它渲染,也是重新启用插件的唯一
2621
- // UI 入口(不能一并关掉,否则卡片消失就没有恢复路径了);其余数据路由一律
2622
- // 403——agent 工具已由 refreshTools 同步清空,这里只管 HTTP 半体。
2623
- 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')) {
2624
2711
  writeJson(res, 403, { error: '插件已禁用(插件配置 → Docker 容器面板 → 启用插件)' });
2625
2712
  return;
2626
2713
  }
@@ -2649,6 +2736,18 @@ const plugin = definePlugin({
2649
2736
  continue;
2650
2737
  patch[key] = body[key];
2651
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
+ }
2652
2751
  // 空 targets 只在显式 clearTargets 时才允许清空:卡片若因启动竞态拿到
2653
2752
  // 空列表,保存不会再把已配置的目标抹掉(防数据丢失)
2654
2753
  if (Array.isArray(patch.targets) && patch.targets.length === 0 && body.clearTargets !== true) {
@@ -2794,8 +2893,10 @@ const plugin = definePlugin({
2794
2893
  }
2795
2894
  // 四条 SSE 都要有「同源证明」(D32):无 Origin 且无 Sec-Fetch-Site 的
2796
2895
  // 请求(旧 Safari / 部分 WebView / 裸 curl)在长流端点上拒绝——浏览器
2797
- // 的 EventSource / fetch 同源请求都会带其中之一
2896
+ // 的 EventSource / fetch 同源请求都会带其中之一;桌面壳的转发链只带
2897
+ // Cookie,由 hasSameOriginProof 内的例外放行(D139)
2798
2898
  if (!hasSameOriginProof(req)) {
2899
+ ctx.logger.warn(`dsh-docker: 拒绝无同源证明的实时流请求 ${sub}(${originProofHint(req)})`);
2799
2900
  writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):实时流端点拒绝无来源请求' });
2800
2901
  return;
2801
2902
  }
@@ -2806,6 +2907,7 @@ const plugin = definePlugin({
2806
2907
  // 变更类端点(写操作)同样要求同源证明(D32);/config 刻意不在名单里:
2807
2908
  // 它是禁用状态下的唯一恢复入口,跨站 POST 已由 loopback + Origin 比对拦住
2808
2909
  if (req.method === 'POST' && MUTATION_SUBROUTES.has(sub) && !hasSameOriginProof(req)) {
2910
+ ctx.logger.warn(`dsh-docker: 拒绝无同源证明的变更请求 ${sub}(${originProofHint(req)})`);
2809
2911
  writeJson(res, 403, { error: '缺少同源证明(需要 Origin 或 Sec-Fetch-Site: same-origin):变更端点拒绝无来源请求' });
2810
2912
  return;
2811
2913
  }
@@ -2818,6 +2920,42 @@ const plugin = definePlugin({
2818
2920
  writeJson(res, 400, { error: 'invalid JSON body' });
2819
2921
  return;
2820
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
+ }
2821
2959
  // 跨目标聚合(0.15.0):target='*' 不是目标名,必须在 pickTarget 之前分流,
2822
2960
  // 否则会被当成「未知目标」直接 400
2823
2961
  const wantsAllTargets = typeof body.target === 'string' && body.target.trim() === '*';
@@ -2941,7 +3079,7 @@ const plugin = definePlugin({
2941
3079
  }
2942
3080
  case '/images/remove': {
2943
3081
  if (!live.allowMutations) {
2944
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3082
+ writeJson(res, 403, { error: mutationsOffMessage() });
2945
3083
  return;
2946
3084
  }
2947
3085
  if (typeof body.ref !== 'string' || body.ref.trim() === '') {
@@ -2953,7 +3091,7 @@ const plugin = definePlugin({
2953
3091
  }
2954
3092
  case '/images/prune': {
2955
3093
  if (!live.allowMutations) {
2956
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3094
+ writeJson(res, 403, { error: mutationsOffMessage() });
2957
3095
  return;
2958
3096
  }
2959
3097
  writeJson(res, 200, { ok: true, result: await api.imagePrune() });
@@ -2973,7 +3111,7 @@ const plugin = definePlugin({
2973
3111
  }
2974
3112
  case '/networks/remove': {
2975
3113
  if (!live.allowMutations) {
2976
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3114
+ writeJson(res, 403, { error: mutationsOffMessage() });
2977
3115
  return;
2978
3116
  }
2979
3117
  if (typeof body.name !== 'string' || body.name.trim() === '') {
@@ -2985,7 +3123,7 @@ const plugin = definePlugin({
2985
3123
  }
2986
3124
  case '/networks/prune': {
2987
3125
  if (!live.allowMutations) {
2988
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3126
+ writeJson(res, 403, { error: mutationsOffMessage() });
2989
3127
  return;
2990
3128
  }
2991
3129
  writeJson(res, 200, { ok: true, result: await api.networkPrune() });
@@ -3005,7 +3143,7 @@ const plugin = definePlugin({
3005
3143
  }
3006
3144
  case '/volumes/remove': {
3007
3145
  if (!live.allowMutations) {
3008
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3146
+ writeJson(res, 403, { error: mutationsOffMessage() });
3009
3147
  return;
3010
3148
  }
3011
3149
  if (typeof body.name !== 'string' || body.name.trim() === '') {
@@ -3017,7 +3155,7 @@ const plugin = definePlugin({
3017
3155
  }
3018
3156
  case '/volumes/prune': {
3019
3157
  if (!live.allowMutations) {
3020
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3158
+ writeJson(res, 403, { error: mutationsOffMessage() });
3021
3159
  return;
3022
3160
  }
3023
3161
  writeJson(res, 200, { ok: true, result: await api.volumePrune() });
@@ -3025,7 +3163,7 @@ const plugin = definePlugin({
3025
3163
  }
3026
3164
  case '/action': {
3027
3165
  if (!live.allowMutations) {
3028
- writeJson(res, 403, { error: '变更操作未启用(插件配置 → Docker 容器面板 → 允许变更操作)' });
3166
+ writeJson(res, 403, { error: mutationsOffMessage() });
3029
3167
  return;
3030
3168
  }
3031
3169
  if (typeof body.id !== 'string') {
@@ -3042,7 +3180,7 @@ const plugin = definePlugin({
3042
3180
  }
3043
3181
  case '/exec': {
3044
3182
  if (!live.allowExec) {
3045
- writeJson(res, 403, { error: 'exec 未启用(插件配置 → Docker 容器面板 → 允许 exec)' });
3183
+ writeJson(res, 403, { error: execOffMessage() });
3046
3184
  return;
3047
3185
  }
3048
3186
  if (typeof body.id !== 'string' || typeof body.command !== 'string') {
@@ -3087,10 +3225,13 @@ const plugin = definePlugin({
3087
3225
  const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
3088
3226
  settingsScope = scope;
3089
3227
  settingsApi = { get: (ns) => readSettingsEntry(settingsCtx, ns) };
3090
- // 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)
3228
+ // 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)。
3229
+ // 走 applySection 而不是直接 normalizeConfig:能力开关的**配置值**要一起记下来(见
3230
+ // configuredCapabilities),否则此后的就地提权重算会拿折叠后的 false 当输入。
3091
3231
  const resolved = scope.get();
3092
- if (typeof resolved === 'object' && resolved !== null)
3093
- live = normalizeConfig(resolved);
3232
+ if (typeof resolved === 'object' && resolved !== null) {
3233
+ applySection(resolved, { forceRefreshTools: true });
3234
+ }
3094
3235
  const diag = (resolved ?? {});
3095
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'})`);
3096
3237
  refreshTools();