@hyzyn/dsh-docker 0.9.6 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-docker",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
4
4
  "description": "DSH Web GUI 的 Docker 容器面板插件:本机或 SSH 主机上的容器列表 / 启停删 / 日志 / 统计 / 镜像 / 一次性 exec,默认只读,配套只读优先的 agent 工具。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "@deepseek-ai/schemastery": "^3.18.4",
47
- "@hyzyn/dsh-kit": "0.5.1",
47
+ "@hyzyn/dsh-kit": "0.5.2",
48
48
  "ssh2": "^1.17.0"
49
49
  },
50
50
  "peerDependencies": {
@@ -2419,6 +2419,195 @@ await test('连接本机:下拉项接的是 connectLocal(哨兵被拦在换
2419
2419
  assert.ok(/return/.test(branch), '哨兵分支必须 return,不能继续走换目标')
2420
2420
  })
2421
2421
 
2422
+ /* ------------------------------------------------------------------ *
2423
+ * 就地提权:多能力 pending → 一条命令解锁全部(一次粘贴)
2424
+ * ------------------------------------------------------------------ */
2425
+
2426
+ /**
2427
+ * 为什么这一组用例必须存在:改前 `elevation` 是**单槽** state(`setElevation({capability})`),
2428
+ * 面板开着时再点另一个开关会把前一个能力的面板**替换**掉、它的命令从界面消失(宿主侧 challenge
2429
+ * 仍在 pending)。2026-09-26 的设置卡片截图实测:两次授权时刻差 14 秒——用户必须在宿主终端
2430
+ * **粘贴两次**。逐能力记录 + 合并复制修掉的就是这件事:两个开关各点一次,界面上同时有两块面板
2431
+ * 与一个「复制全部」,终端**粘贴一次**两行。
2432
+ *
2433
+ * 断言打在**渲染树**与**按钮交下去的字节**上(不只是纯函数):纯函数对而接线错(按钮接错回调、
2434
+ * 只复制了第一条、顺序反了)在真机上才暴露,正是离线冒烟要拦的那一类。
2435
+ */
2436
+ function elevationApi() {
2437
+ const exports_ = registration.factory((spec) => SEED[spec])
2438
+ assert.ok(exports_.__elevation !== undefined, '缺少 __elevation 测试缝')
2439
+ return exports_.__elevation
2440
+ }
2441
+
2442
+ /** 面板组的渲染:回调用桩记录,配置值默认都关着(与「开关还没打开」的真实初态一致)。 */
2443
+ function renderElevationPanels(records, overrides = {}) {
2444
+ const api = elevationApi()
2445
+ const copied = []
2446
+ const closed = []
2447
+ const regenerated = []
2448
+ const tree = api.view(records, {
2449
+ now: overrides.now ?? 1_700_000_000_000,
2450
+ copiedKey: overrides.copiedKey ?? '',
2451
+ configured: overrides.configured ?? (() => false),
2452
+ onCopy: (capability, command) => copied.push({ capability, command }),
2453
+ onCopyAll: (text) => copied.push({ capability: api.COPY_ALL_KEY, command: text }),
2454
+ onRegenerate: (capability) => regenerated.push(capability),
2455
+ onClose: (capability) => closed.push(capability),
2456
+ onDisableFirst: () => {},
2457
+ })
2458
+ return { api, tree, copied, closed, regenerated }
2459
+ }
2460
+
2461
+ /** 合并按钮:从渲染树里找(而不是读入参),保证「界面上真的有」这件事被测到。 */
2462
+ function mergeButtonOf(tree) {
2463
+ return treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copyAll')[0]
2464
+ }
2465
+
2466
+ await test('就地提权:两个 pending → 合并按钮复制的内容 = 两条命令按发起顺序的换行连接(逐字节)', () => {
2467
+ const api = elevationApi()
2468
+ const expiresAt = 1_700_000_000_000 + 300_000
2469
+ // 发起顺序:先点 allowMutations,再点 allowExec(数组顺序就是点击顺序)
2470
+ const records = [
2471
+ { capability: 'allowMutations', command: "touch '/home/u/.dsh/dsh-kit/grant-confirm/aaa'", expiresAt },
2472
+ { capability: 'allowExec', command: "touch '/home/u/.dsh/dsh-kit/grant-confirm/bbb'", expiresAt },
2473
+ ]
2474
+ const { tree, copied } = renderElevationPanels(records)
2475
+ const button = mergeButtonOf(tree)
2476
+ assert.ok(button !== undefined, '两个 pending 时必须出「复制全部」按钮')
2477
+
2478
+ /*
2479
+ * 驱动按钮的 onClick(真跑一次「点了会怎样」):断言的是**交给复制动作的那个字符串**
2480
+ * (桩里没有 navigator,剪贴板本身是浏览器的事)——内容错(少一条 / 顺序反 / 用 && 连接)
2481
+ * 才是本用例要拦的。
2482
+ */
2483
+ button.props.onClick()
2484
+ assert.equal(copied.length, 1, '点一次合并按钮应恰好触发一次复制')
2485
+ const text = copied[0].command
2486
+ const expected = records[0].command + '\n' + records[1].command
2487
+ assert.equal(text, expected, '合并内容必须逐字节等于两条命令的换行连接')
2488
+ assert.equal(
2489
+ Buffer.from(text, 'utf8').toString('base64'),
2490
+ Buffer.from(expected, 'utf8').toString('base64'),
2491
+ '按字节比对(换行符形态也是契约)',
2492
+ )
2493
+ assert.equal(text.split('\n').length, 2, '两条命令 = 两行')
2494
+ assert.ok(!text.includes('&&'), '不得用 && 连接(PS 5.1 不认)')
2495
+ assert.ok(!text.includes('; '), '不得用 ; 连接(cmd 不认)')
2496
+ assert.ok(text.indexOf(records[0].command) < text.indexOf(records[1].command), '顺序必须是发起顺序(先点的在前)')
2497
+ assert.equal(copied[0].capability, api.COPY_ALL_KEY, '合并复制的反馈键必须与单条按钮隔离')
2498
+
2499
+ // 每块面板自己的单条复制按钮仍在(用户仍可只复制一条),且只复制自己那条
2500
+ const singleButtons = treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copy')
2501
+ assert.equal(singleButtons.length, 2, '两块面板各自保留单条复制按钮')
2502
+ singleButtons[0].props.onClick()
2503
+ assert.equal(copied[1].command, records[0].command, '单条复制只复制自己那条命令')
2504
+ assert.equal(copied[1].capability, 'allowMutations', '单条复制的反馈键是能力名(与合并按钮互不串)')
2505
+ })
2506
+
2507
+ await test('就地提权:单个 pending → 无合并按钮(那条命令就在自己的面板里)', () => {
2508
+ const expiresAt = 1_700_000_000_000 + 300_000
2509
+ const single = [{ capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt }]
2510
+ const { tree, api } = renderElevationPanels(single)
2511
+ assert.equal(mergeButtonOf(tree), undefined, '只点过一个开关时不该出现「复制全部(1 条)」')
2512
+ // 反面判据:纯判定同样为假(防止某天有人把「≥2」放宽成「≥1」而用例只看渲染树)
2513
+ assert.equal(api.shouldOfferMerge(single, 1_700_000_000_000), false)
2514
+ // 界面结构不变形:一块面板 + 一个单条复制按钮
2515
+ assert.equal(treeFind(tree, (node) => node.props?.className === 'dk_elevPanel').length, 1)
2516
+ assert.equal(treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copy').length, 1)
2517
+ })
2518
+
2519
+ await test('就地提权:一个 granted 后 → 合并按钮只剩剩下那一条(组里还有别的能力没解锁)', () => {
2520
+ const api = elevationApi()
2521
+ const expiresAt = 1_700_000_000_000 + 300_000
2522
+ const two = [
2523
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt },
2524
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2525
+ ]
2526
+ // allowMutations 先解锁:记录留痕(面板消失),命令清掉
2527
+ const afterGrant = api.markGranted(two, 'allowMutations')
2528
+ assert.equal(api.shouldOfferMerge(afterGrant, 1_700_000_000_000), true, '还有一条没解锁时按钮要留着')
2529
+ assert.deepEqual(api.merge(afterGrant, 1_700_000_000_000), ["touch '/tmp/b'"], '只剩仍 pending 的那一条')
2530
+ assert.equal(api.text(afterGrant, 1_700_000_000_000), "touch '/tmp/b'", '合并内容退化成那一行(没有多余换行)')
2531
+
2532
+ const { tree, copied } = renderElevationPanels(afterGrant)
2533
+ const button = mergeButtonOf(tree)
2534
+ assert.ok(button !== undefined, '一个 granted 后合并按钮仍在')
2535
+ button.props.onClick()
2536
+ assert.equal(copied[0].command, "touch '/tmp/b'", '按钮此刻只复制仍 pending 的那条')
2537
+ // 已授权的那块面板消失,仍 pending 的那块还在
2538
+ assert.equal(treeFind(tree, (node) => node.props?.className === 'dk_elevPanel').length, 1, '已授权的面板必须消失')
2539
+
2540
+ // 两条都解锁 → 组里没有可复制命令:留痕被收掉,按钮自然消失
2541
+ const allGranted = api.markGranted(afterGrant, 'allowExec')
2542
+ assert.deepEqual(allGranted, [], '全部解锁后整组清空(含留痕)')
2543
+ assert.equal(
2544
+ api.view(allGranted, { copiedKey: '', configured: () => false, onCopy() {}, onCopyAll() {}, onRegenerate() {}, onClose() {}, onDisableFirst() {} }),
2545
+ null,
2546
+ '全部结束后面板组不渲染',
2547
+ )
2548
+ })
2549
+
2550
+ await test('就地提权:过期的那条不进合并(复制过去只会让宿主落一个过期后才出现的文件)', () => {
2551
+ const api = elevationApi()
2552
+ const now = 1_700_000_000_000
2553
+ const records = [
2554
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt: now - 1 },
2555
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt: now + 300_000 },
2556
+ ]
2557
+ assert.deepEqual(api.merge(records, now), ["touch '/tmp/b'"], '过期的从合并里剔掉')
2558
+ const { tree, copied } = renderElevationPanels(records, { now })
2559
+ const button = mergeButtonOf(tree)
2560
+ assert.ok(button !== undefined, '组里仍有两条记录时按钮在(只是内容少了一条)')
2561
+ button.props.onClick()
2562
+ assert.equal(copied[0].command, "touch '/tmp/b'", '合并按钮只复制仍 pending 的那些')
2563
+ // 两条都过期 → 没有可复制的:按钮收起(不再承诺一个不可能发生的解锁)
2564
+ const bothExpired = records.map((record) => ({ ...record, expiresAt: now - 1 }))
2565
+ assert.equal(api.shouldOfferMerge(bothExpired, now), false)
2566
+ })
2567
+
2568
+ await test('就地提权:✕ 只关一个能力的面板,另一个照常解锁(不撤销宿主 challenge)', () => {
2569
+ const api = elevationApi()
2570
+ const expiresAt = 1_700_000_000_000 + 300_000
2571
+ const records = [
2572
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt },
2573
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2574
+ ]
2575
+ const { tree, closed } = renderElevationPanels(records)
2576
+ const closeButtons = treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'close')
2577
+ assert.equal(closeButtons.length, 2, '每块面板各有自己的收起按钮')
2578
+ closeButtons[0].props.onClick()
2579
+ assert.deepEqual(closed, ['allowMutations'], '✕ 只收起被点的那一个能力')
2580
+ // 关掉 A 之后 B 照常解锁:记录集里仍留着 B,合并内容只剩 B 那条
2581
+ const afterClose = api.remove(records, 'allowMutations')
2582
+ assert.deepEqual(api.merge(afterClose, 1_700_000_000_000), ["touch '/tmp/b'"])
2583
+ assert.equal(api.shouldOfferMerge(afterClose, 1_700_000_000_000), false, '只剩一个 pending 时合并按钮收起')
2584
+ })
2585
+
2586
+ await test('就地提权:两条命令各自独立——一条出错 / 在途不影响另一条的合并', () => {
2587
+ const api = elevationApi()
2588
+ const expiresAt = 1_700_000_000_000 + 300_000
2589
+ const records = [
2590
+ { capability: 'allowMutations', error: 'HTTP 429' },
2591
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2592
+ ]
2593
+ assert.deepEqual(api.merge(records, 1_700_000_000_000), ["touch '/tmp/b'"], '出错那条没有命令,不进合并')
2594
+ assert.equal(api.shouldOfferMerge(records, 1_700_000_000_000), false, '只有一条可复制时不出按钮')
2595
+ // 在途(begin 还没回)同样不参与合并
2596
+ assert.deepEqual(api.merge([{ capability: 'allowExec' }], 1_700_000_000_000), [])
2597
+ })
2598
+
2599
+ await test('就地提权:i18n 双份齐(新增键 zh/en 都在,且都进了产物)', () => {
2600
+ const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
2601
+ for (const key of ['elev.copyAll', 'elev.copyAllHint', 'elev.copyAllCopied']) {
2602
+ const hits = source.split(`'${key}':`).length - 1
2603
+ assert.equal(hits, 2, `${key} 必须在 zh / en 两份目录里各出现一次(现在是 ${String(hits)} 次)`)
2604
+ }
2605
+ const decoded = decodeBundle(code)
2606
+ assert.ok(decoded.includes('复制全部'), 'bundle 里应有合并按钮的中文兜底文案')
2607
+ assert.ok(code.includes('dk_elevPanels'), 'bundle 里应有面板组的样式钩子')
2608
+ assert.ok(code.includes('dk_elevMerge'), 'bundle 里应有合并复制行的样式钩子')
2609
+ })
2610
+
2422
2611
  /* ------------------------------------------------------------------ *
2423
2612
  * 结果
2424
2613
  * ------------------------------------------------------------------ */
@@ -181,6 +181,8 @@ function makeCtx(config, options = {}) {
181
181
  settingsStored: {},
182
182
  ttyConfig: options.ttyConfig,
183
183
  disposers: [],
184
+ // 宿主日志捕获:info / warn 都进这里(其余宿主侧断言用)
185
+ logs: [],
184
186
  /**
185
187
  * 本插件 entry 的安装级配置(= apply 拿到的 config)。DSH ≥0.1.7 的
186
188
  * `settings.describe()` 返回的是「默认值 ← 组合 base ← 用户层」的 resolved 值,
@@ -202,7 +204,10 @@ function makeCtx(config, options = {}) {
202
204
  return () => {}
203
205
  }
204
206
  const child = {
205
- logger: { info: () => {}, warn: () => {} },
207
+ logger: {
208
+ info: (msg) => { state.logs.push(msg) },
209
+ warn: (msg) => { state.logs.push(msg) },
210
+ },
206
211
  effect: (callback, _name) => {
207
212
  const dispose = callback()
208
213
  state.disposers.push(typeof dispose === 'function' ? dispose : () => {})
@@ -1185,6 +1190,143 @@ await test('重启路径:settings 命名空间已禁用时挂载 → 挂载即
1185
1190
  assert.equal(config.body.config.enabled, false)
1186
1191
  })
1187
1192
 
1193
+ /* ------------------------------------------------------------------ *
1194
+ * 7.5 能力使用审计(capability-use):过闸的变更 / exec 每次一行
1195
+ * ------------------------------------------------------------------ */
1196
+
1197
+ const USE_PREFIX = '[dsh-docker] capability-use: '
1198
+ /*
1199
+ * 审计行的出口是宿主 stdout(console.log,docker D162)——断言面因此是 console 捕获,
1200
+ * 不是假 ctx 的 logger(那是其余宿主侧断言用的)。捕获行原样放行,不影响人工排障。
1201
+ */
1202
+ const consoleLogs = []
1203
+ const origConsoleLog = console.log.bind(console)
1204
+ console.log = (...args) => {
1205
+ consoleLogs.push(args.map((arg) => String(arg)).join(' '))
1206
+ origConsoleLog(...args)
1207
+ }
1208
+ const useLines = () => consoleLogs.filter((line) => line.startsWith(USE_PREFIX))
1209
+ /** 前段字段无空格;detail 恒在行尾且值可含空格、可含像 key=value 的文本(exec 命令)——从最后一个 ` detail=` 处整段吃下。 */
1210
+ function parseUseLine(line) {
1211
+ const fields = {}
1212
+ const body = line.slice(USE_PREFIX.length)
1213
+ const detailAt = body.lastIndexOf(' detail=')
1214
+ const head = detailAt === -1 ? body : body.slice(0, detailAt)
1215
+ for (const pair of head.split(' ')) {
1216
+ const eq = pair.indexOf('=')
1217
+ if (eq > 0) fields[pair.slice(0, eq)] = pair.slice(eq + 1)
1218
+ }
1219
+ if (detailAt !== -1) fields.detail = body.slice(detailAt + ' detail='.length)
1220
+ return fields
1221
+ }
1222
+ /** 跑一条变更路由,断言它**恰好**产生一行 use 行且指定字段逐一对上。 */
1223
+ async function auditCase(sub, body, expected) {
1224
+ const before = useLines().length
1225
+ const res = await call('POST', sub, body)
1226
+ assert.equal(res.status, 200, `${sub} 前置:路由本身要成功`)
1227
+ const lines = useLines().slice(before)
1228
+ assert.equal(lines.length, 1, `${sub} 应恰好产生一行 use 行,实际 ${String(lines.length)} 行`)
1229
+ const fields = parseUseLine(lines[0])
1230
+ for (const [key, value] of Object.entries(expected)) {
1231
+ assert.equal(fields[key], value, `${sub}: 字段 ${key}`)
1232
+ }
1233
+ assert.equal(fields.target, '本机', `${sub}: target`)
1234
+ assert.equal(fields.ok, 'true', `${sub}: ok`)
1235
+ assert.notEqual(Number(fields.durationMs), NaN, `${sub}: durationMs 应是数字`)
1236
+ return fields
1237
+ }
1238
+
1239
+ await test('审计:八条变更 / exec 路由各恰好一行,字段与操作一一对应', async () => {
1240
+ await auditCase('/images/remove', { target: '本机', ref: 'nginx:1.27' }, { capability: 'allowMutations', source: 'http', action: 'image.remove', ref: 'nginx:1.27' })
1241
+ const prune = await auditCase('/images/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'image.prune' })
1242
+ assert.equal('ref' in prune, false, 'prune 无目标:ref 字段应省略')
1243
+ await auditCase('/networks/remove', { target: '本机', name: 'shop_default' }, { capability: 'allowMutations', source: 'http', action: 'network.remove', ref: 'shop_default' })
1244
+ await auditCase('/networks/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'network.prune' })
1245
+ await auditCase('/volumes/remove', { target: '本机', name: 'pgdata' }, { capability: 'allowMutations', source: 'http', action: 'volume.remove', ref: 'pgdata' })
1246
+ await auditCase('/volumes/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'volume.prune' })
1247
+ await auditCase('/action', { target: '本机', action: 'start', id: 'shop-web-1' }, { capability: 'allowMutations', source: 'http', action: 'container.start', ref: 'shop-web-1' })
1248
+ await auditCase('/exec', { target: '本机', id: 'shop-web-1', command: 'echo hi' }, { capability: 'allowExec', source: 'http', action: 'exec', ref: 'shop-web-1', detail: 'code=0 cmd=echo hi' })
1249
+ })
1250
+
1251
+ await test('审计:pull 流开记 start、收尾记 end(reason / code / durationMs)', async () => {
1252
+ const before = useLines().length
1253
+ const res = await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')
1254
+ assert.equal(res.status, 200)
1255
+ const lines = useLines().slice(before)
1256
+ assert.equal(lines.length, 2, 'pull 流应恰好产生 start + end 两行')
1257
+ const start = parseUseLine(lines[0])
1258
+ assert.equal(start.event, 'start')
1259
+ assert.equal(start.action, 'image.pull')
1260
+ assert.equal(start.ref, 'nginx:1.27')
1261
+ assert.equal('ok' in start, false, 'start 行还没有结果:不带 ok')
1262
+ const end = parseUseLine(lines[1])
1263
+ assert.equal(end.event, 'end')
1264
+ assert.equal(end.ok, 'true')
1265
+ assert.equal(end.reason, 'pull-exit')
1266
+ assert.equal(end.code, '0')
1267
+ assert.notEqual(Number(end.durationMs), NaN)
1268
+ })
1269
+
1270
+ await test('审计:只读路由与 /connect-local 产生零 use 行', async () => {
1271
+ const before = useLines().length
1272
+ for (const [method, sub, body] of [
1273
+ ['POST', '/probe', { target: '本机' }],
1274
+ ['POST', '/containers', { target: '本机' }],
1275
+ ['POST', '/inspect', { target: '本机', id: 'shop-web-1' }],
1276
+ ['POST', '/logs', { target: '本机', id: 'shop-web-1' }],
1277
+ ['POST', '/stats', { target: '本机', ids: ['shop-web-1'] }],
1278
+ ['POST', '/images', { target: '本机' }],
1279
+ ['POST', '/networks', { target: '本机' }],
1280
+ ['POST', '/volumes', { target: '本机' }],
1281
+ ['POST', '/connect-local', {}],
1282
+ ]) {
1283
+ const res = await call(method, sub, body)
1284
+ assert.equal(res.status, 200, `${sub} 前置`)
1285
+ }
1286
+ assert.equal(useLines().length - before, 0, '只读操作与 /connect-local 不该产生 use 行')
1287
+ })
1288
+
1289
+ await test('审计:agent 工具 source=tool(不经 HTTP 路由,不与路由重复计数)', async () => {
1290
+ const before = useLines().length
1291
+ const action = state.tools.find((item) => item.name === 'docker_action')
1292
+ assert.ok(action !== undefined)
1293
+ await action.execute({ target: '本机', action: 'stop', id: 'shop-web-1' })
1294
+ const exec = state.tools.find((item) => item.name === 'docker_exec')
1295
+ assert.ok(exec !== undefined)
1296
+ await exec.execute({ target: '本机', id: 'shop-web-1', command: 'ls -la' })
1297
+ const lines = useLines().slice(before)
1298
+ assert.equal(lines.length, 2, '两个工具各恰好一行')
1299
+ const stop = parseUseLine(lines[0])
1300
+ assert.equal(stop.source, 'tool')
1301
+ assert.equal(stop.capability, 'allowMutations')
1302
+ assert.equal(stop.action, 'container.stop')
1303
+ assert.equal(stop.ref, 'shop-web-1')
1304
+ const execLine = parseUseLine(lines[1])
1305
+ assert.equal(execLine.source, 'tool')
1306
+ assert.equal(execLine.capability, 'allowExec')
1307
+ assert.equal(execLine.action, 'exec')
1308
+ assert.equal(execLine.detail, 'code=0 cmd=ls -la')
1309
+ })
1310
+
1311
+ await test('审计:403 不记(使用 = 过了闸);开关恢复后一切照旧', async () => {
1312
+ const before = useLines().length
1313
+ const off = await call('POST', '/config', { allowMutations: false, allowExec: false })
1314
+ assert.equal(off.status, 200)
1315
+ const action = await call('POST', '/action', { target: '本机', action: 'stop', id: 'shop-web-1' })
1316
+ assert.equal(action.status, 403)
1317
+ const exec = await call('POST', '/exec', { target: '本机', id: 'shop-web-1', command: 'ls' })
1318
+ assert.equal(exec.status, 403)
1319
+ const pull = await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')
1320
+ assert.equal(pull.status, 403)
1321
+ assert.equal(useLines().length - before, 0, '被闸挡回的请求不算使用')
1322
+
1323
+ // 恢复开关:后续用例(若有)不受影响,工具重新注册
1324
+ const on = await call('POST', '/config', { allowMutations: true, allowExec: true })
1325
+ assert.equal(on.status, 200)
1326
+ assert.equal(toolNames().includes('docker_action'), true)
1327
+ await auditCase('/action', { target: '本机', action: 'start', id: 'shop-web-1' }, { capability: 'allowMutations', source: 'http', action: 'container.start', ref: 'shop-web-1' })
1328
+ })
1329
+
1188
1330
  /* ------------------------------------------------------------------ *
1189
1331
  * 8. 结果
1190
1332
  * ------------------------------------------------------------------ */